[C#] 람다 식과 클로저 (Lambda Expression & Closure)

Donghee·2024년 3월 20일
0

람다 식

클로저를 알기 위해선 람다 식부터 알아야 한다. 람다 식은 C#에서 익명 함수(Anonymous Fuction)을 표현하는 하나의 방식이다. 람다 식은 기본적으로 => 연산자를 활용해 매개변수와 본문을 연결하는 식으로 만든다.

(매개변수) => 본문;

람다는 식 하나를 지정하는 식 람다와, 중괄호 안에 본문을 지정해주는 문 람다로 나뉜다.

식 람다

(매개변수) => 식;

Action<string> print = (string s) => Console.Write(s);

위와 같은 식으로 활용한다.
델리게이트의 매개변수, 리턴 형식으로 암시적으로 하나의 매개변수의 타입을 확정지을 수 있을 때는 괄호와 타입을 명시하지 않아도 괜찮다.

Func<int, int> power = num => num * num;

물론 매개변수가 2개 이상일 때는 괄호로 덮어주어야 한다.

Func<int, int, int> multiply = (num1, num2) => num1 * num2;

문 람다

(매개변수) => { 본문 내용... };

Action printRandom = () =>
{
    Random a = new Random();
    int randomNum = a.Next(0, 10);

    Console.Write(randomNum);
};

식 람다와 차이는 중괄호 안에 여러 줄을 적을 수 있다는 점뿐, 나머지는 동일하다.

캡처와 클로저

람다 식은 람다 식 밖에서 선언되었으나, 그 안에서 사용된 변수들을 캡처한다. 흔히 화면을 캡처한다는 개념으로 많이 쓰는 용어인데, 여기서도 비슷하다. 캡처는 컴파일러가 람다 식에서 쓰이는 기존의 변수를 별도의 클래스(컴파일러가 생성)에 그대로 찍어 가져오는 과정을 의미한다. 이런 캡처된 변수들을 사용하는 람다 식을 특별하게 클로저(Closure)라고 부른다.

public class CaptureTest {
    void Main()
    {
        int value = 10;
        Action<int> multiply = (int num) => value = value * num;
        // multiply Action에서 value 변수를 캡처

        multiply(value);
        // value에 10 * 10으로 100이 저장된다.
        Console.WriteLine(value); // 100
    }
}

위의 (int num) => value = value * num 람다식에서 기존 변수 value를 캡처하게 되므로, 이 람다식은 클로저가 된다. 이렇게 만들어진 클로저는 외부에서 가져온 변수들을 값만 가져오는 것이 아니라, 변수 그 자체를 참조하게 된다.

그렇다면 클로저는 외부 변수를 어떻게 참조하는 것이고, 클로저 자체는 어떻게 저장되어 있을까?
SharpLab을 통해 컴파일러가 만든 내용을 확인해보았다.

public class CaptureTest
{
    [CompilerGenerated]
    private sealed class <>c__DisplayClass0_0
    {
        public int value;

        internal void <Main>b__0(int num)
        {
            value *= num;
        }
    }

    private void Main()
    {
        <>c__DisplayClass0_0 <>c__DisplayClass0_ = new <>c__DisplayClass0_0();
        <>c__DisplayClass0_.value = 10;
        Action<int> action = new Action<int>(<>c__DisplayClass0_.<Main>b__0);
        action(<>c__DisplayClass0_.value);
        Console.WriteLine(<>c__DisplayClass0_.value);
    }
}

컴파일러가 람다 식을 작동시키는 원리는 다음과 같다.
1. 람다 식을 처리할 <>c__DisplayClass0_0 이라는 클래스를 만들어준다.
2. 이 클래스에 람다 식의 함수를 멤버 메서드로 넣는다.
3. 캡처하는 변수는 클래스 안에 public 멤버 변수로 캡처한다.
4. 이후 람다 식을 쓰는 메서드에 만든 클래스의 인스턴스를 하나 생성한다.
5. 캡처한 변수는 그 클래스의 멤버변수로 대체하고, 람다 식 또한 동일하다.

사실 변수를 레퍼런스 방식으로 참조하는 것이 아니라, 별도의 클래스 멤버 변수로 만들어 여기서 변수 자체를 관리해준다.
또한 익명 함수가 델리게이트 객체 자체에 저장되는 방식이 아니고, 컴파일러가 임의의 클래스를 만들어 익명 함수들을 저장해두고 처리하는 방식이다.

람다 식 vs 클래스 멤버 메서드

캡처된 변수를 사용하는 것과, 그냥 멤버 메서드를 사용하는 것에 어떤 차이가 있는지 알아보자.

public class CaptureTest {
    public class A
    {
        public int number;
        public A(int number) { this.number = number; }
        public void print() { Console.Write($"{number}\n"); }
    }

    void Main()
    {
        A instanceA = new A(1);
        Action a = () => { instanceA.print(); };
        Action b = instanceA.print;
        
        instanceA = new A(2);
        
        // Main에 있는 instanceA 자체를 print함
        a(); // 2
        // 대입될 때 참조했던 number가 1인 A 클래스 객체를 참조 중
        b(); // 1
    }
}

위의 코드의 instanceA에 집중해보자. 첫 초기화때는 number가 1인 A 객체였다. Action a에는 람다식을 활용해 number를 print해주고, Action b에는 바로 객체의 멤버 메서드 print를 호출하게 하였다. 그 후 instanceA에게 number가 2인 새 A 객체를 지니게 했다.
실행하면 Action a는 2를 출력, Action b는 1을 출력하게 된다. 이는 Action a에서는 instanceA를 캡처해 변수 자체를 관리하고 있고, Action b에서는 계속 number가 1인 A 클래스 객체의 멤버 함수를 지니고 있기 때문이다.
상세하게 디버그 결과로 살펴보자.

Action a의 Target은 컴파일러가 생성해낸 별도의 객체를 지정하고 있고, Action b는 그대로 A 클래스 객체를 지정하고 있다. 결론적으로, Action a에서는 람다 식에서 캡처가 진행되어 instanceA의 변화를 계속 따라갈 수 있었다. 반면, Action b에서는 클래스 객체의 멤버 메서드만 값으로 받았기 때문에, instanceA가 가리키는 객체가 바뀌더라도 처음 가리키던 객체와 연결이 되어 있다. 따라서 instanceA라는 그릇이 변화하는 것에는 별 신경을 쓰지 않았던 것이다.

헷갈릴 수 있는 내용이니 람다 식으로 캡처가 되는지를 명확히 파악 후 사용하도록 하자. (이 사실을 몰라 꽤 고생했었다..)

레퍼런스

profile
마포고개발짱

0개의 댓글