C# Closure 이해하기

[제목] C# Closure 이해하기

Closure란 무엇인가? C#에서 Closure는 어떻게 구현되는가? C#에서 Closure는 어떤 곳에 사용되는가?
이 아티클은 이러한 질문에 대한 답변을 정리한 글이다.
 

C# Closure

C# Closure는 C# 2.0 부터 지원된 기능으로서 C#의 무명메서드(Anonymous Method)와 람다식(Lambda Expression)으로 구현할 수 있다. 먼저 간단히 무명메서드를 이용하여 Closure를 사용한 예를 살펴보자.

다음은 간단한 무명메서드를 정의하여 print라는 델리게이트 객체에 할당한 예이다. 여기서 정의된 delegate 블럭은 작은 함수라 볼 수 있는데, 이 함수는 단순히 콘솔 출력 문장 하나로 되어 있다. 이것은 Closure가 아니다.

public void Test1()
{
    Action print = delegate()
    {
        Console.WriteLine("A");
    };
    print();
}

그러면 좀 더 복잡한(?) 예로 delegate 함수에서 파라미터를 받아들이는 예를 살펴보자. 아래 예제는 msg라는 입력 파라미터를 받아들여 이를 콘솔에 출력하는 코드이다.이것도 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 란 무엇인가? Closure를 어렵게 학술적으로 정의하면 Lexcial scope내의 Free variable을 사용하는 일급함수(First-class function)이다. 그리고 C#의 용어로 쉽게 풀어서 설명하면, 무명메서드나 람다식이 그것을 정의(define)하고 있는 메서드(outer method)의 로컬변수(Outer 파라미터 포함)를 사용하고 있을 때, 그 무명메서드 혹은 람다식을 Closure라 부른다. 위의 Test3 예제는 라인7 string str = key + msg 에서 이 무명메서드를 정의한 메서드(Test3() 메서드)의 로컬변수 key를 사용하고 있다. 따라서, 이 delegate 무명메서드는 Closure라 부를 수 있다.

함수형프로그래밍에서 함수(function)의 출력은 항상 입력파라미터에만 의존하여 만들어 진다. 즉, 만약 함수가 f(x) = y 라면 함수 f는 입력파라미터 x가 동일하다면 항상 동일한 결과 y를 생성한다. 함수 f가 동일한 입력에 대해 동일한 출력을 하기 위해서는 그 함수가 OOP의 객체처럼 어떤 상태(state)를 갖거나 혹은 Mutable 데이타를 포함해서는 안된다. 따라서 일반적으로 함수형프로그래밍의 함수는 입력파라미터와 그 함수 내의 로컬변수만을 사용하며 외부의 다른 상태에 의존하지 않는다.

이러한 일반적 함수와 달리, Closure는 그 함수를 정의하고 있는 바깥 쪽(lexcial scope의) 함수의 로컬변수(주: Outer 로컬변수를 Free Variable라 부른다)를 사용하면서 그 Free Variable을 자신의 내부로 끌어들여 포함(close over)하고 있는 일급함수이다. 여기서 일급함수란 함수를 일종의 데이타 타입처럼 취급할 수 있을 때 즉, 함수를 다른 메서드/함수의 파라미터로 이리 저리 전달해서 사용할 수 있을 때 이를 일급함수라 부른다. C#에서 무명메서드나 람다식은 델리게이트에 담아 이리 저리 전달할 수 있으므로 일급함수라 볼 수 있다.
 

그러면 이러한 Closure는 어디에 사용하는가?

기본적으로 Closure는 일급함수로서 전달할 수 있는 함수인데, 함수를 이리 저리 전달해서 사용할 때, 그 함수가 처음 정의될 때의 Context를 그대로 가지고 있을 필요가 있는 경우가 있다. 즉, Closure는 함수가 Context를 보유할 수 있도록 한 기능으로서 여기서의 Context는 일반적으로 Closure 함수를 정의한 바깥(outer) 함수의 변수들이다. 그러면 C#에서 이러한 기능이 필요한 곳이 어디 있을까? C#에서 Closure를 가장 많이 사용하는 부분은 물론 LINQ 일 것이다.

아래 예제에서 GetList() 메서드의 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# 컴파일러는 C# Closure에 맞는 Nested Class를 생성하게 된다(아래 설명).

만약 Closure가 없었다면, LINQ는 아마 지금보다 훨씬 복잡하게 사용되어 졌을 것이다.


C# 컴파일러의 Closure 구현

그렇다면 C#은 어떻게 Closure를 구현한 것일까? C#에서 Closure는 컴파일러에 의해 구현되었으며, 런타임시는 Closure만을 위해 특별히 무언가를 하지 않는다. 우선 C# 컴파일러가 Closure에 대해 어떤 특별한 처리를 해 주는가를 살펴보자. 다음과 같이 3개의 클래스를 만들고 이를 빌드하였을 때 MSIL이 어떻게 생성되는지를 살펴보자. (ILDASM : File -> Dump) 

// 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;
    }
}


마지막으로 다음 예제의 결과를 생각해 보자. 이것이 실행되면 출력이 1일까, 10일까? 위에서 언급하였듯이 C# 컴파일러는 Closure의 경우 Nested 클래스를 만들고 변수 a를 Nested 클래스의 필드로 둔다고 하였다. 그리고, Outer 메서드의 내용은 이 Nested 클래스 객체를 가리키도록 변형된다고 하였다.

class MyClass5
{
    public void DoTest()
    {
        int a = 1;
        Action act = () => Console.WriteLine(a);
        a = 10;
        act();            
    }
}

따라서, 위의 코드는 개념적으로 다음과 같이 변형될 것이다. 즉 코드에서 n.a를 다시 10으로 변형한 후 메서드를 호출하였으므로 결과는 10이 출력된다.

class MyClass5
{
    class Nested1
    {
        public int a;
        public void DoTestMethod()
        {
            Console.WriteLine(a);
        }
    }

    public void DoTest()
    {
        Nested1 n = new Nested1();
        n.a = 1;

        n.a = 10;
        n.DoTestMethod();
    }
}

그렇다면 다음 코드의 결과는 무엇일까? (힌트: Outer 로컬변수가 몇 개인가?)

public void DoTest()
{
	List<Action> list = new List<Action>();
	for (int i = 0; i < 10; i++)
	{
		list.Add(() => Console.WriteLine(i));
	}

	list.ForEach(p => p());
}


본 웹사이트는 광고를 포함하고 있습니다. 광고 클릭에서 발생하는 수익금은 모두 웹사이트 서버의 유지 및 관리, 그리고 기술 콘텐츠 향상을 위해 쓰여집니다.