Log Stash

as an Industrial Personnel

프로그래밍

C#의 람다 변수 캡쳐

SavvyTuna 2017. 5. 29. 00:16
보통 UI뷰 클래스를 만들때 최대한 데이터 클래스와의 연관성을 줄이려고 한다. 그 노력의 일환으로 요즘 회사 게임 프로젝트에서 버튼 이벤트 리스너를 구현해야할 때, 가능하면 뷰 클래스 자체는 그냥 리스너 대리자만 가지고 있게 작성하고, 리스너의 본체는 뷰를 생성하는 시점에서 만들어서 전파하는 식으로 구현하고 있다.
class PseudoButton 
{
    public delegate void VoidDelegate();

    private VoidDelegate onClick;

    // 뷰 만들때 여기로 리스너를 넘겨준다.
    public void SetOnClick(VoidDelegate onClick) 
    {
        this.onClick = onClick;
    }

    // UI 게임 오브젝트에서 이 함수로 이벤트를 넘겨주면, 그냥 리스너를 호출하기만 한다.
    public void OnClick() 
    {
        this.onClick?.Invoke();
    }
}

대략 이렇게.

물론 목적성이 뚜렷한 UI는 이렇게 작성하는게 별 의미 없을 수 있지만, 최대한 다른곳에서 돌려써야 하는 UI는 경험적으로 이렇게 구현하는것이 좀 더 나았던 것 같다.

그러다가 아래 예시와 비슷하게 10개의 동일한 뷰 스크립트가 붙어있는, index값만 조금 다른 버튼을 구현해야 할 일이 있었다. 그래서 별 생각 없이 for문을 돌아가며 i값을 기준으로 각자 할 일을 하는 리스너를 붙여줬다.

아래의 예시 코드를 보자. 처음에 작성하면서 나는 이 코드가 "I am 5th button" 을 출력할 것이라고 생각했었다.

using System;
using System.Collections;

namespace TestProject
{
    class MainClass
    {
        public static void Main(string[] args)
        {
            // 가짜 버튼을 10개를 만들어서, 
            PseudoButton[] buttons = new PseudoButton[10];

            // 각 버튼마다 클릭 이벤트 때 할 일들을 정해준다.
            for (int i = 0; i < 10; i++) 
            {
                buttons[i] = new PseudoButton();

                buttons[i].SetOnClick(() =>
                {
                    Console.WriteLine(string.Format("I am {0}th button", i));
                });
            }
            
            // "I am 5th button"이 출력되길 기대한다.
            buttons[5].OnClick();
        }
    }

    // 여기선 단순히 버튼의 onClick 메소드만 보관하는 가짜 버튼 클래스
    class PseudoButton 
    {
        public delegate void VoidDelegate();

        private VoidDelegate onClick;

        public void SetOnClick(VoidDelegate onClick) 
        {
            this.onClick = onClick;
        }

        public void OnClick() 
        {
            // 유니티에서 못 쓰는거 여기에서라도 써보자 
            this.onClick?.Invoke();
        }
    }
}

하지만 결과는 "I am 10th button" 이라고 나온다. 혹시나 싶어서 다른 버튼을 눌러봐도, 버튼 0~9가 전부 다 똑같이 10번째 버튼이라고 말한다. 아마 for문이 끝나면 i의 값이 마지막으로 10이 되기 때문인것 같은데, 어쨌거나 내가 원했던 동작은 아니다.

여기서 원하는 대로 각 대리자에게 원하는 값의 변수를 전달되게 하기 위해선, 루프 내부에서 로컬 변수를 만들고, 이를 대리자에게 전달해야한다.

for (int i = 0; i < 10; i++) 
{
    // 루프 내부의 로컬 변수에 값을 복사해 놓고,
    int localCopy = i;
    buttons[i] = new PseudoButton();

    buttons[i].SetOnClick(() =>
    {
        // 로컬 변수를 전달한다.
        Console.WriteLine(string.Format("I am {0}th button", localCopy));
    });
}

이렇게 하면 원래 기대했던 결과인 "I am 5th button"가 출력된다.

일단 저렇게 수정하고 커밋을 올려놓긴 했는데, '왜 저럴까? for문 안에선 어차피 i가 로컬 변수일텐데, 그걸 다른곳에 복사한다고 뭐가 달라질까?'라고 생각하다가 이 아티클을 보게 되었다.

아티클에 따르면 람다함수 안에서 사용하고 있는 변수는 C# 컴파일러가 컴파일을 하면서 따로 익명의 private 클래스의 멤버로 옮겨지고, 람다함수의 내용도 그 클래스의 멤버로 옮겨지게 된다. 이 때 처음의 잘못된 로직은 이렇게 바뀌게 될 것이다.

private class InnerClass 
{
    public int i;

    public void lambdaMethod()
    {
        Console.WriteLine(string.Format("I am {0}th button", i));
    }
}

public static void Main(string[] args)
{
    PseudoButton[] buttons = new PseudoButton[10];

    // 루프 밖에서 생성
    InnerClass innerClass = new InnerClass();
    for (int i = 0; i < 10; i++) 
    {
        innerClass.i = i;
        buttons[i] = new PseudoButton();
        buttons[i].SetOnClick(innerClass.lambdaMethod);
    }

    buttons[5].OnClick();
}

i가 루프를 뛰어넘어 접근 가능한 변수니까 루프 바깥에서 innerClass를 생성할테고, 그 멤버인 innerClass.i를 루프 내부에선 계속 최신화만 시켜주고 있으니 buttons[5].OnClick();가 불릴때는 루프가 종료되던 시점의 i값인 10을 출력하게 되는것이다.

반면에, 로컬 변수에 값을 복사했던 로직은 아래와 같이 바뀌게 될 것이다.

private class InnerClass 
{
    public int localCopy;

    public void lambdaMethod()
    {
        Console.WriteLine(string.Format("I am {0}th button", localCopy));
    }
}

public static void Main(string[] args)
{
    PseudoButton[] buttons = new PseudoButton[10];

    for (int i = 0; i < 10; i++) 
    {
        // 루프 안에서 생성
        InnerClass innerClass = new InnerClass();
        innerClass.localCopy = i;

        buttons[i] = new PseudoButton();
        buttons[i].SetOnClick(innerClass.lambdaMethod);
    }

    buttons[5].OnClick();
}

innerClass 자체를 루프 내부에서 생성하고, 그 멤버를 i값으로 세팅한다. 루프가 다 끝나더라도 각 인스턴스에 저장된 localCopy값은 그대로 있기 때문에, buttons[5].OnClick() 시점에서 리스너를 호출하더라도 세팅할 때의 인덱스 값을 그대로 출력하게 된다.

다른 스크립트 언어의 클로져랑 동작이 좀 다른것 같다. CLR에서 런타임에 캡쳐된 변수들에 대한 레퍼런스를 가지고 있게 할 줄 알았는데, IEnumerator로 제네레이터 함수를 만드는 것 처럼 컴파일러가 처리해준다니.

참조

https://www.codeproject.com/Articles/15624/Inside-C-Anonymous-Methods#4