Lambda Capture
이전 프로젝트에서 있었던 현상인데 꽤나 생소한 개념이었기에 따로 정리를 해보려고 한다. 문제가 발생한 코드는 다음과 같다.
for (int i = 0; i < _slots.Count; i++)
{
_slots[i].onClick.AddListener(()=>DataManager.Instance.SaveData(i));
}
의도한 것은 각 슬롯 버튼마다 슬롯 번호에 해당하는 파일에 데이터를 저장하는 기능이었지만 6개의 버튼을 눌렀을 때 모두 SaveFile_6번에 데이터를 저장하는 결과가 발생했다. 당시에는 대체 왜 이러는지 알 수 없었지만, 혹시나 해서 지역 변수를 넣어봤더니 의도한 대로 저장되는 것을 볼 수 있었다.
for (int i = 0; i < _slots.Count; i++)
{
int slotIndex = i;
_slots[slotIndex].onClick.AddListener(()=>DataManager.Instance.SaveData(slotIndex));
}
물론 당시에는 원인을 알 수 없었지만, 추후 알아보니 문제는 람다 캡처라는 현상 때문이었다.
Capture/Closure
아래의 예시 코드를 살펴보자.
List<Action> actions = new List<Action>();
for (int i = 0; i < 3; i++)
{
actions.Add(() => Console.WriteLine(i));
}
foreach (var a in actions)
{
a.Invoke();
}
// 기대 결과 : 0, 1, 2
// 실제 결과 : 3, 3, 3
위의 코드에서는 i라는 변수를 가상 함수가 직접 참조하여 actions에 추가되는 것을 볼 수 있는데, 이때 참조하는 변수 i를 Capture, 가상함수를 Closure라고 부른다. 즉 Lambda Capture는 현상이 아닌 Lambda식에서 사용되는 가상함수가 참조하는 변수를 의미하는 것이다.
이때 컴파일러는 Capture와 Closure를 하나의 임시 Class로 묶어버리는데 이를 코드로 표현하면 다음과 같다.
public class TempClass
{
public int Capture; // i
public void Closure // 가상 함수
{
Console.WriteLine(Capture);
}
}
즉 람다식을 사용하는 경우 컴파일러는 힙영역에 임시 클래스를 생성하여 다음과 같이 저장하는 것이다. 이렇게 되면 class의 특징인 참조 형식이 될 것이고, 당연히 나중에 호출되는 가상함수들에 대해 마지막에 저장된 Capture의 값인 3으로 대입되는 것이다.
그렇다면 for문에서 바로 호출되는 경우라면 어떻게 될까?
List<Action> actions = new List<Action>();
for (int i = 0; i < 3; i++)
{
actions.Add(() => Console.WriteLine(i));
actions[i].Invoke();
}
// 기대 결과 : 0, 1, 2
// 실제 결과 : 0, 1, 2
이 경우에는 실제 결과가 기대한 것과 일치하는 것을 볼 수 있다. 그 이유는 Capture(i)의 값이 덮어씌워지기 전에 해당 값으로 Closure를 호출하기 때문이다. 람다 캡처에서 주의해야하는 것은 람다식을 통해 이벤트를 구독하고 나중에 사용하는 경우에 해당한다.
Closure 분리
그렇다면 이러한 현상을 방지하기 위해서는 어떻게 해야할까? 정답은 Closure를 분리하는 것이다.
List<Action> actions = new List<Action>();
for (int i = 0; i < 3; i++)
{
int index = i;
actions.Add(() => Console.WriteLine(index));
}
foreach (var a in actions)
{
a.Invoke();
}
// 기대 결과 : 0, 1, 2
// 실제 결과 : 0, 1, 2
위와 같이 Capture로 i를 직접 사용하는 것이 아닌, 지역변수를 활용하여 값형식으로 바꾸게 되면 컴파일러가 생성하는 임시 클래스에도 차이가 생긴다.
public class TempClass
{
public void Closure(int index) // 가상 함수
{
Console.WriteLine(index);
}
}
이렇게 Capture를 필드로 가지는 것이 아닌 매개변수로써 사용하게 되어 Closure를 분리하는 것처럼 만드는 것이다.
그렇다면 다시 맨 위에서 소개한 문제의 코드로 돌아가 문제를 파악해보자.
for (int i = 0; i < _slots.Count; i++)
{
_slots[i].onClick.AddListener(()=>DataManager.Instance.SaveData(i));
}
현재 코드에서는 람다식을 사용하여 가상함수를 이벤트에 구독하고 있는 상황이다. 여기서 문제는 가상함수 Closure가 Capture로 i를 직접 사용하고 있다는 점인데 이렇게 되면 해당 이벤트를 for문 내부에서 바로 호출하는 것이 아닌 이상 모든 Closure에 대해 동일한 값(i=_slots.Count
)을 참조하게 되는 문제가 발생하는 것이다.
따라서 이를 해결하기 위해서는 Capture로 i를 직접 사용하는 것이 아닌, 지역 변수로 캐싱하여 값형식으로 바꿔줄 필요가 있다.
for (int i = 0; i < _slots.Count; i++)
{
int slotIndex = i;
_slots[slotIndex].onClick.AddListener(()=>DataManager.Instance.SaveData(slotIndex));
}
이와 같이 작성함으로써 기대한 바와 동일하게 기능을 구현하는 것이 가능한 것이다.
위의 내용을 요약하면 다음과 같다.
람다에서 외부 변수를 캡처하면 컴파일러는 그 변수를 담는 클래스를 생성한다. 이 변수는 참조로 남기 때문에, 나중에 호출할 때 원하지 않는 값이 들어갈 수 있다. 이를 피하려면 지역 변수에 복사해서 캡처해야 한다.