class Invoker
{
private Action action;
public Invoker() => action = delegate { };
public void AddAction(Action action) => this.action += action;
public void Invoke() => this.action.Invoke();
}
class Program
{
private static void Test(bool isLambda)
{
Invoker invoker = new Invoker();
Action action = delegate { };
action += () => Console.WriteLine("Invoke");
invoker.AddAction(
isLambda ? () => action.Invoke()
: action.Invoke
);
action += () => Console.WriteLine("Invoke");
invoker.Invoke();
}
private static void Main(string[] args)
{
Console.WriteLine("-------- Test Invoke() for lambda --------");
Test(isLambda: true);
Console.WriteLine("-------- Test Invoke() for method --------");
Test(isLambda: false);
}
}
위 코드의 실행 결과를 예측할 수 있겠는가? 얼핏 보면 Invoke
가 네 번 호출될 것 같지만 실제론 그렇지 않다:
어떻게 이런 일이 벌어질 수 있는 것일까? 이러한 출력 결과가 나타난 이유를 자세히 파헤쳐보려 한다.
delegate
는 reference type
? value type
? Invoke
가 한 번만 출력된 (isLambda
가 false
인) 상황에 대해 먼저 살펴보려 한다. 실행 결과를 이해하기 위해선 앞서 소개한 Action
자체를 Delegate
로 변환할 수 있어야 한다:
using System;
delegate void MyAction();
class Invoker
{
private MyAction action;
public Invoker() => action = delegate { };
public void AddAction(MyAction action)
=> this.action = (MyAction) Delegate.Combine(this.action, action);
public void Invoke() => action();
}
class Program
{
private static void Test()
{
Invoker invoker = new Invoker();
MyAction action = delegate { };
action = (MyAction) Delegate.Combine(
action, new MyAction(() => Console.WriteLine("Invoke"))
);
invoker.AddAction(new MyAction(action.Invoke));
action = (MyAction) Delegate.Combine(
action, new MyAction(() => Console.WriteLine("Invoke"))
);
invoker.Invoke();
}
private static void Main(string[] args)
{
Test();
}
}
(참조: https://sharplab.io/) (.NET Framework (x64))
isLambda
가 false
인 상황에서의 코드는 위와 같을 것이다. 여기에서 AddAction
으로 전달한 action
과 그 다음 행의 action
이 완전히 다른 객체임에 주목하라. 이는 Delegate.Combine()
이 객체를 새롭게 생성하기 때문이다. 결과적으로 AddAction
이후에 추가된 delegate
는 invoker.Invoke()
에 의해 호출되지 않는다.
이는 delegate
가 reference type
임에도 불구하고 마치 value type
과 같이 동작하는 특이성을 가지기 때문이다.
그렇다면 다시 뒤짚어서, 왜 isLambda
가 true
인 상황에선 Invoke
가 터미널에 두 번 출력된 것일까? AddAction
으로 전달된 action
객체는 AddAction
이후의 객체와 분명히 다를 것이다. 이를 Lambda Expression
으로 바꾼다 하더라도 delegate
의 기본 규칙은 바뀌지 않을 것이기 때문이다.
delegate void MyAction();
class Invoker
{
private MyAction action;
public Invoker() => action = delegate { };
public void AddAction(MyAction action)
=> this.action = (MyAction) Delegate.Combine(this.action, action);
public void Invoke() => this.action.Invoke();
}
class Program
{
private static void Test()
{
Invoker invoker = new Invoker();
MyAction action = delegate { };
action = (MyAction) Delegate.Combine(
action, new MyAction(() => Console.WriteLine("Invoke"))
);
invoker.AddAction(() => action.Invoke());
action = (MyAction) Delegate.Combine(
action, new MyAction(() => Console.WriteLine("Invoke"))
);
invoker.Invoke();
}
private static void Main(string[] args)
{
Test();
}
}
방금 # 1. delegate 는 reference type? value type?
에서 소개한 코드에서 AddAction
딱 한 줄, 그러니까 26 행 전달 인자만 수정했다. 그런데 실행결과는 놀랍게도 두 번의 Invoke
가 호출이 된다:
여기에 대해서는 Lambda Expression
의 Variable Scope
에 대해서 이해할 필요가 있다. 위 코드를 sharplab.io
에 긁어서 붙여 넣으면 코드를 다음과 같이 변환해준다:
internal class Program
{
[Serializable]
[CompilerGenerated]
private sealed class <>c
{
public static readonly <>c <>9 = new <>c();
public static MyAction <>9__0_0;
public static MyAction <>9__0_1;
public static MyAction <>9__0_3;
internal void <Test>b__0_0()
{
}
internal void <Test>b__0_1()
{
Console.WriteLine("Invoke");
}
internal void <Test>b__0_3()
{
Console.WriteLine("Invoke");
}
}
[CompilerGenerated]
private sealed class <>c__DisplayClass0_0
{
public MyAction action;
internal void <Test>b__2()
{
action();
}
}
private static void Test()
{
<>c__DisplayClass0_0 <>c__DisplayClass0_ = new <>c__DisplayClass0_0();
Invoker invoker = new Invoker();
<>c__DisplayClass0_.action = <>c.<>9__0_0 ?? (<>c.<>9__0_0 = new MyAction(<>c.<>9.<Test>b__0_0));
<>c__DisplayClass0_.action = (MyAction)Delegate.Combine(<>c__DisplayClass0_.action, <>c.<>9__0_1 ?? (<>c.<>9__0_1 = new MyAction(<>c.<>9.<Test>b__0_1)));
invoker.AddAction(new MyAction(<>c__DisplayClass0_.<Test>b__2));
<>c__DisplayClass0_.action = (MyAction)Delegate.Combine(<>c__DisplayClass0_.action, <>c.<>9__0_3 ?? (<>c.<>9__0_3 = new MyAction(<>c.<>9.<Test>b__0_3)));
invoker.Invoke();
}
private static void Main(string[] args)
{
Test();
}
}
코드가 좀 뭐같긴 하지만 AddAction
으로 전달되는 action
객체와 여기에 lambda expression
이 추가되는 코드만 잘 살펴보면 된다. 코드가 복잡해서 다 분석할 순 없으나 하나 확실하게 알 수 있는 점은 이전 예제에서 본 것처럼 action
은 lambda expression
이 추가될 때마다 변경된다는 것이다.
그러나 이전 예제와 달리 action
은 c__DisplayClass0_0
라는 기묘한 클래스로 래핑되어 있고 AddAction
으로 전달하는 메서드는 action.Invoke()
가 아닌 c__DisplayClass0_0
클래스 내에 정의된 b__2()
라는 점이다. b__2()
는 다시 action()
을 호출하게 된다. (action()
은 action.Invoke()
와 동치; null conditional operator
를 제외한)
이는 lambda expression
이 outer variable
을 capture
하기 때문이다. lambda expression
은 위에서 본 것처럼 lambda expressoin
외부에 정의된 변수를 가져다 쓸 수 있는데 위 상황에서는 이하의 규칙이 적용된다:
lambda expression
외부에서 접근한 변수) 는 delegate
자체가 가비지 컬렉터의 수집 대상이 되기 전까진 마찬가지로 수집되지 않는다.in
, ref
혹은 out
의 매개변수로 사용된 대상을 직접적으로 캡쳐할 수 없다. (간접적으로는 가능)return
문은 둘러싼 메서드를 반환하는 결과를 야기하지 않는다.goto
, break
, continue
문을 포함할 수 없다. 그 역 또한 가질 수 없다. 위 규칙, 그 중에서도 1 번 규칙에 따라 action
은 Test()
함수가 반환되기 전까진 불멸(?) 의 객체가 되며 AddAction()
함수가 호출하게 되는 action
또한 Test()
에서 처음 정의한 동일한 action
객체를 가르키게 된다.
[Web] https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/lambda-expressions
[Web] https://sharplab.io/
[Web] https://learn.microsoft.com/en-us/dotnet/api/system.delegate?view=net-7.0
[Web] https://learn.microsoft.com/en-us/dotnet/csharp/delegate-class
위 문제를 해결하는데 지대한 도움을 주신 김찬중 선생님께 감사의 말씀을 드립니다.