C# Closure
Closure는 C# 2.0부터 지원된 기능으로서 C#의 무명메서드(Anonymous Method)와 람다식(Lambda Expression)으로 구현할 수 있다. 먼저 간단히 무명메서드를 이용하여 Closure를 사용한 예를 살펴보자.
다음은 간단한 무명메서드를 정의하여 print라는 델리게이트 객체에 할당한 예이다. 여기서 정의된 delegate 블록은 작은 함수라 볼 수 있는데, 이 함수는 단순히 콘솔 출력 문장 하나로 되어 있다. 이것이 Closure가 아니다.
public void Test2()
{
Action<string> print = delegate (string msg)
{
Console.WriteLine(msg);
};
print("A");
}
이제 한걸음 더 나아가 Test() 메서드의 로컬변수인 key를 delegate 함수 블록에서 사용해 보자. 아래 코드를 실행하면 10A가 출력되는데, 이는 key 변숫값과 delegate 입력 파라미터를 붙여서 나온 결과이다. 여기서 한 가지 주목할 것은 delegate 함수가 입력파라미터나 함수 내 로컬 변수 이외에 함수 밖에 존재하는 key라는 변수를 사용했다는 점이다. 이것이 Closure를 표현한 예제이다.
public void Test3()
{
int key = 10;
Action<string> print = delegate (string msg)
{
string str = key + msg;
Console.WriteLine(str);
};
print("A"); // 10A
}
Closure란 무엇인가?
Closure를 어렵게 학술적으로 정의하면 Lexical scopre 내의 free variable을 사용하는 일급함수(first-class function)이다. 그리고 C#의 용어로 쉽게 풀어서 설명하면, 무명메서드나 람다식이 그것을 정의(define)하고 있는 메서드(outer method)의 로컬변수(Outer 파라미터 포함)를 포함하고 있을 때, 그 무명메서드 혹은 람다식을 Closure라 부른다. 위의 Test3 예제는 라인 7 string str = key + msg에서 이 무명 메서드를 정의한 메서드 Test()의 로컬변수 key를 사용하고 있다. 따라서 이 delegate 무명메서드는 Closure라고 부를 수 있다.
함수형 프로그래밍에서 함수(function)의 출력은 항상 입력파라미터에만 의존하여 만들어진다. 즉, 만약 함수가 f(x) = y 라면 함수 f는 입력 파라미터 x가 동일하다면 항상 동일한 결과 y를 생성한다. 함수 f가 동일한 입력에 대해 동일한 출력을 하기 위해서는 그 함수가 OOP의 객체처럼 어떤 상태(state)를 갖거나 혹은 Mutable 데이터를 포함해서는 안된다. 따라서 일반적으로 함수형 프로그래밍의 함수는 입력 파라미터와 그 함수 내의 로컬변수만을 사용하며 외부의 다른 상태에 의존하지 않는다.
이러한 일반적 함수와 달리, Closure는 그 함수를 정의하고 있는 바깥쪽(lexical scopre) 함수의 로컬 변수를 사용하면서 그 Free Variable을 자신의 내부로 끌어들여 포함(close over)하고 있는 일급함수이다. 여기서 일급함수란 함수를 일종의 데이터타입처럼 취급할 수 있을 때 즉, 함수를 다른 메서드/함수의 파라미터로 이리저리 전달해서 사용할 수 있을 때 이를 일급함수라고 부른다. C#에서 무명메서드나 람다식은 델리게이트에 담아 이리저리 전달할 수 있으므로 일급함수라 볼 수 있다.
그러면 Closure는 어디에 사용하는가?
기본적으로 Closure는 일급함수로서 전달할 수 있는 함수인데, 함수를 이리저리 전달해서 사용할 때, 그 함수가 처음 정의될 때의 Context를 그대로 가지고 있을 필요가 있는 경우가 있다. 즉, Closure는 함수가 Context를 보유할 수 있도록 한 기능으로서 여기서의 Context는 일반적으로 Closure 함수를 정의한 바깥 함수의 변수들이다. 그러면 C#에서 이러한 기능이 필요한 곳은 어디 있을까? C# Closure를 가장 많이 사용하는 부분은 물론 LINQ 일 것이다.
class Class1
{
private List<string> list = new List<string>()
{
"A", "AB", "ABC", "ABCD", "ABCDE"
};
public IList<string> GetList(int maxLength)
{
//int maxLength = 3;
var limitedList = list.Where(p => p.Length <= maxLength).ToList();
return limitedList;
}
}
LINQ의 Where 확장메서드 ProtoType은 다음과 같은데 여기서 predicate 파라미터는 bool이 리턴되는 메서드 혹은 람다식을 받아들인다.
public static IEnumerable<TSource> Where<TSource>(
this IEnumerable<TSource> source,
Func<TSource,bool> predicate
)
GetList() 예제에서 Where() 메서드의 파라미터는 람다식으로서 문자열의 길이가 지정된 변수 maxLength 값 이하인 경우 참을 리턴하는 조건식이다. 그런데, 이 람다식에서 사용하는 maxLength은 람다식의 입력파라미터가 아닌 Outer 메서드 GetList()의 입력 파라미터이다. 따라서, 이 람다식은 Closure가 되며 C# 컴파일러는 Closure에 맞는 Nested Class를 생성하게 된다.
C# 컴파일러의 Closure 구현
C#에서 Closure는 컴파일러에 의해 구현되었으며, 런타임시는 Closure만을 위해 특별히 무언가를 하지 않는다. 우선 C# 컴파일러가 Closure에 대해 어떤 특별한 처리를 해 주는가를 살펴보자. 다음과 같이 3개의 클래스를 만들고 이를 빌드하였을 때 MSIL이 어떻게 생성되는지를 살펴보자.
// Static Method 생성
class MyClass1
{
public void Run()
{
Action act = () => Console.WriteLine("a");
}
}
// Instance Method 생성
class MyClass2
{
int a = 1; // 필드
public void Run()
{
Action act = () => Console.WriteLine(a);
}
}
// Nested Class 생성 (Closure)
class MyClass3
{
public void Run()
{
int a = 1; // Outer 로컬변수
Action act = () => Console.WriteLine(a);
}
}
먼저 MyClass1처럼 람다식에 외부 변수/필드를 전혀 사용하지 않을 경우, C# 컴파일러는 해당 람다식에 대해 static 메서드를 만든다. 그리고 MyClass2처럼 람다식에 클래스 객체 필드를 사용한 경우, C# 컴파일러는 해당 람다식에 대하여 instance 메서드로 변형한다. 마지막으로 MyClass3처럼 람다식에 Outer 메서드의 변수를 사용한 경우, C# 컴파일러는 해당 람다식에 대하여 새로운 Nested 클래스를 만들고 그 Nested 클래스 안에 Free Variable을 필드로 추가하고 람다식을 메서드로 추가한다. 그리고 Outer 메서드 자체를 새로 생성된 Nested 클래스 객체의 필드를 액세스 하고 그 메서드를 호출하도록 변형한다.
.class private auto ansi beforefieldinit CloApp.MyClass1 extends [mscorlib]System.Object
{
.field private static class [mscorlib]System.Action 'CS$<>9__CachedAnonymousMethodDelegate1'
.method public hidebysig instance void Run() cil managed
.method private hidebysig static void '<Run>b__0'() cil managed
}
.class private auto ansi beforefieldinit CloApp.MyClass2 extends [mscorlib]System.Object
{
.field private int32 a
.method public hidebysig instance void Run() cil managed
.method private hidebysig instance void '<Run>b__0'() cil managed
}
.class private auto ansi beforefieldinit CloApp.MyClass3 extends [mscorlib]System.Object
{
// Nested Class for Closure
.class auto ansi sealed nested private beforefieldinit '<>c__DisplayClass0' extends [mscorlib]System.Object
{
.field public int32 a
.method assembly hidebysig instance void '<Run>b__2'() cil managed
}
.method public hidebysig instance void Run() cil managed
}
컴파일러는 왜 이런 변형을 해주는 것일까?
다음의 예제를 살펴보자. DoTest()를 실행하면 1,2를 출력한다. 그런데 자세히 보면 int a변수는 GetAction() 메서드의 로컬변수이다. 로컬변수는 그 메서드를 벗어나는 즉시 스택에서 지워지기 때문에 이 변수 a는 라인 5에서 GetAction() 실행이 끝나고 action 델리게이트 객체로 할당되는 순간 사라져야 맞다. 하지만 이 로컬 변수 a는 사라지지 않았을 뿐 아니라 a++된 값을 계속해서 가지고 있다.
Closure를 사용할 때 Outer 로컬변수(좀 더 정확히 Free Variable)가 스택에서 사라지는 현상을 막기 위해 C# 컴파일러는 Closure에 대해 특별한 처리를 해주게 되었다. 즉, Outer 로컬변수를 스택에 두지 않고 Heap에 두려고 했으며 따라서 별도의 Type 즉 Nested Class를 새로 생성한 것이다. Nested Class는 'Outer 로컬변수 a를 필드에 저장하고 람다식을 메서드에 저장한다.' 이렇게 되면 Heap에 있는 Nested 객체는 계속 상태를 유지할 수 있게 된다. 물론 더 이상 참조하는 객체가 없으면 이 Nested 객체는 GC에 의해 해제된다.
연습 코드
class MyClass4
{
public void DoTest()
{
Action action = GetAction();
action(); // 1
action(); // 2
}
private Action GetAction()
{
int a = 1; // Free variable
Action act = () =>
{
Console.WriteLine(a);
a++;
};
return act;
}
}
class MyClass5
{
public void DoTest()
{
int a = 1;
Action act = () => Console.WriteLine(a);
a = 10;
act();
}
}
static void Main()
{
MyClass5 myClass = new MyClass5();
myClass.DoTest(); // 10
myClass.DoTest(); // 10
}
출처
'프로그래밍 > C#' 카테고리의 다른 글
Environment.TickCount (2) | 2023.08.24 |
---|---|
C# 인터페이스 이해하기 (8) | 2023.08.03 |
C# 무명 메서드(Anonymous Method) (4) | 2023.08.02 |
C# 7.0 튜플(Tuple) (6) | 2023.07.27 |
C# Queue 기본 생성자로 초기화 하기 (18) | 2023.07.26 |
댓글