2024. 1. 3. 00:21ㆍUnity, C# 프로그래밍
1. C# 의 람다는 capture by reference 만 존재한다
internal class Program
{
static void Main(string[] args)
{
List<Action> actions = new List<Action>(10);
for (int i = 0; i < 10; ++i)
{
actions.Add(() => { Console.WriteLine(i); });
}
foreach (Action action in actions)
{
action.Invoke();
}
}
}
(많이 나오는 주의 사항이라 다들 알고 있을 것 같지만 주의사항 모음이니 추가)
C# 의 람다 식을 처음 접한 사람들은
위의 코드를 보고 출력이 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 와 같이 나올 것이라고 예측하는 사람도 있는데,
C#은 변수를 값으로 캡처하는 기능을 지원하지 않는다.
위 코드는 for문이 종료될 때의 i 값인 10을 10번 출력하게 된다.
왜 이런 결과가 나오는 지는 디컴파일러로 확인해보면 되는데
아래는 위 예제를 디컴파일러를 이용해서 확인해 본 결과다. (DotPeek의 Low-Level C# Viewer를 사용했다)
internal class Program
{
private static void Main(string[] args)
{
List<Action> actions = new List<Action>(10);
Program.<>c__DisplayClass0_0 cDisplayClass00 = new Program.<>c__DisplayClass0_0();
for (cDisplayClass00.i = 0; cDisplayClass00.i < 10; ++cDisplayClass00.i)
actions.Add(new Action((object) cDisplayClass00, __methodptr(<Main>b__0)));
List<Action>.Enumerator enumerator = actions.GetEnumerator();
try
{
while (enumerator.MoveNext())
enumerator.Current();
}
finally
{
enumerator.Dispose();
}
}
public Program()
{
base..ctor();
}
[CompilerGenerated]
private sealed class <>c__DisplayClass0_0
{
public int i;
public <>c__DisplayClass0_0()
{
base..ctor();
}
internal void <Main>b__0()
{
Console.WriteLine(this.i);
}
}
}
예제에서 for 문에서 사용되는 i 변수는 스택 공간에 있던 변수를 사용하는 것처럼 보였지만
실제 동작 시에는 람다 식의 capture by reference를 구현하기 위해서 DisplayClass를 만들고 그 클래스 인스턴스의 i 변수를 참조한다.
이 문제를 수정하고 0부터 9까지 출력하고 싶다면 i 를 캡처할 때 for 문의 내부에 임시 변수를 만들고 해당 변수를 캡처하면 된다.
for (int i = 0; i < 10; ++i)
{
int _i = i;
actions.Add(() => { Console.WriteLine(_i); }); // 변수 i 대신 _i 를 캡처한다.
}
이렇게 수정하게 되면 DisplayClass를 10번 만드는 식으로 동작이 바뀌어 원하는 결과를 얻을 수 있다.
for (int i = 0; i < 10; ++i)
{
Program.<>c__DisplayClass0_0 cDisplayClass00 = new Program.<>c__DisplayClass0_0();
cDisplayClass00._i = i;
actions.Add(new Action((object) cDisplayClass00, __methodptr(<Main>b__0)));
}
2. 조건문과 람다 캡처 문제
보통 위의 케이스는 너무 유명해서 모두들 알고 있을 것 같다.
하지만 위의 동작을 알고 있음에도 실수할 수 있는 부분이 있는데
바로 람다 캡처와 if문을 같이 사용했을 때
로직 상 도달할 수 없는 위치에서의 람다 캡처가 DisplayClass를 만들어버리는 문제다.
아래 예제의 CreateDelegate 함수는 람다 캡처를 이용해서 인자 count 만큼 숫자를 출력하는 델리게이트를 반환한다.
단, count가 0인 경우 No Action을 출력하는 델리게이트를 반환한다.
internal class Program
{
static void Main(string[] args)
{
Action action = CreateDelegate(0);
action.Invoke();
}
private static Action CreateDelegate(int count)
{
if (count == 0)
{
return () => { Console.WriteLine("No Action."); };
}
return () =>
{
for (int i = 0; i < count; ++i)
{
Console.WriteLine("Print: " + i);
}
};
}
}
이때 count가 0이라면 아래쪽 로직을 타지 않기 때문에
람다 캡처를 위한 DisplayClass의 생성이 일어나지 않을 것이라고 생각할 수 있는데
그렇지 않다. count의 값이 뭐든 DisplayClass가 생성된다.
왜 이렇게 되는지는 디컴파일러로 확인해 볼 수 있는데
private static Action CreateDelegate(int count)
{
Program.<>c__DisplayClass1_0 cDisplayClass10 = new Program.<>c__DisplayClass1_0();
cDisplayClass10.count = count;
if (cDisplayClass10.count == 0)
return Program.<>c.<>9__1_0 ?? (Program.<>c.<>9__1_0 = new Action((object) Program.<>c.<>9, __methodptr(<CreateDelegate>b__1_0)));
return new Action((object) cDisplayClass10, __methodptr(<CreateDelegate>b__1));
}
위 디컴파일 결과를 보면 count의 검사 수행 전에 먼저 DisplayClass를 생성하고 그 이후에 if 문 검사를 수행하게 된다.
.NET 런타임 위에서 실행하면 세대별 GC가 동작하기 때문에 큰 문제는 없지만,
유니티에서 위와 같은 코드를 할당이 발생하지 않을것이라고 생각하고 호출이 빈번한 곳에 사용했다면 문제가 생길 수 있다. (유니티는 GC 구현이 세대별 GC가 아니기 때문에 가비지 수집 비용이 비싸다)
또 문제가 발생했을때 디컴파일러로 확인해보지 않거나 이런 특징을 모르고 있었다면 찾기 힘들기 때문에 알아두는게 좋다.
3. 인자로 Delegate를 사용하는 경우 문제
이것도 사실 유니티 엔진 위의 C# 에서 주의해야 할 점인데
인자로 Delegate를 받는 상황은 게임 개발에서 굉장히 많이 사용된다.
아래 코드는 Delegate를 사용하는 예시로
Case A는 함수를 바로 SetCallback 함수 인자로 전달하고 있고
Case B는 함수를 람다로 한번 감싼 후에 함수 인자로 전달한다.
보통 이 두개의 차이점이 없을 거라고 생각하고 많이 사용한다.
internal class Program
{
private static void DoSomething()
{
// 무언가를 하는 함수.
}
private static void SetCallback(Action callback)
{
// TODO - 콜백을 받아 실행하는 함수.
}
static void Main(string[] args)
{
// Case A
for (int i = 0; i < 100; ++i)
{
SetCallback(DoSomething);
}
// Case B
for (int i = 0; i < 100; ++i)
{
SetCallback(() => DoSomething());
}
}
}
하지만 디컴파일러로 결과물을 확인해 본다면
internal class Program
{
private static void DoSomething()
{
}
private static void SetCallback(Action callback)
{
}
private static void Main(string[] args)
{
for (int i = 0; i < 100; ++i)
Program.SetCallback(new Action((object) null, __methodptr(DoSomething)));
for (int i = 0; i < 100; ++i)
Program.SetCallback(Program.<>c.<>9__2_0 ?? (Program.<>c.<>9__2_0 = new Action((object) Program.<>c.<>9, __methodptr(<Main>b__2_0))));
}
public Program()
{
base..ctor();
}
[CompilerGenerated]
[Serializable]
private sealed class <>c
{
public static readonly Program.<>c <>9;
public static Action <>9__2_0;
static <>c()
{
Program.<>c.<>9 = new Program.<>c();
}
public <>c()
{
base..ctor();
}
internal void <Main>b__2_0()
{
Program.DoSomething();
}
}
}
Case A 는 매 루프마다 Action Delegate를 생성하고
Case B 는 Program.<>c.<>9__2_0 이라는 전역 공간에 Action Delegate를 캐싱해서 사용하고 있는 모습을 볼 수 있다.
(디컴파일러 결과물이라 이름이 난해하게 되어있다)
만약 유니티 내의 Update나 LateUpdate등의 호출이 잦은 곳에 Case A 처럼 사용하고 있다면
성능에 좋지 않을 수 있으니
Case B 처럼 사용하거나 또는 Action 객체를 미리 생성해서 명시적으로 재사용 하는 방법이 있을 수 있다.
C# + 유니티 조합에서 델리게이트 및 람다 사용시 조심해야 할 부분을 모아둔 자료로
앞으로도 추가할 만한 내용이 있다면 문서를 업데이트 해 두겠습니다.
그리고 위에 설명한 내용들 중에 언어 구현 세부 사양이 아닌 부분은 언제든지 최적화 될 수 있으므로
항상 프로파일링 후에 병목 지점을 찾고 나서 적용하는게 좋습니다.
(Delegate에 바로 대입 시 캐싱 등 최신 .NET 런타임에서 컴파일 했을 때 최적화 되는 경우도 많습니다)
'Unity, C# 프로그래밍' 카테고리의 다른 글
유니티 에셋 번들 빌드 시간 단축 사례 (0) | 2024.02.01 |
---|---|
유니티 C# 개발 시 사용하기 좋은 툴 및 사이트 (2) | 2024.01.03 |
XML 데이터 로딩 최적화 사례 (1) | 2023.12.28 |
유니티 ScriptedImporter 써보기 2부 (0) | 2023.07.14 |
유니티 ScriptedImporter 써보기 1부 (0) | 2023.07.13 |