본문 바로가기
프로그래밍/C#

C# Closure 이해하기

by bantomak 2023. 8. 2.

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# Closure 이해하기 - C# 프로그래밍 배우기 (Learn C# Programming)

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

www.csharpstudy.com

'프로그래밍 > 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

댓글