델리게이트 심화
함수형 인터페이스와 조합입니다.
함수를 주고받습니다
3.4에서 메서드를 값처럼 담았고, 3.5에서 이름 없이 적는 법을 배웠습니다. 여기서는 그것을 매개 변수로 받고, 반환값으로 돌려주고, 이어 붙이는 이야기입니다.
함수를 받거나 돌려주는 함수를 고차 함수라고 합니다.
Where·Select 가 이미 그것이고
(3.3), 직접 만들면 같은 자리에 다른
규칙을 끼워 넣을 수 있습니다.
뒤쪽 절반은 비용입니다. 델리게이트는 만들 때마다 객체가 생기는데, 어떤 것은 생기고 어떤 것은 생기지 않습니다. 그 차이가 어디서 오는지를 잽니다.
이어 붙이기
아래 코드는 브라우저 안에서 실제로 실행됩니다. 고쳐서 눌러 보셔도 됩니다.
// 함수 둘을 받아 하나로 이어 붙입니다. 이것이 고차 함수입니다. static Func<TIn, TOut> Then<TIn, TMid, TOut>( Func<TIn, TMid> first, Func<TMid, TOut> next) => x => next(first(x)); var trim = (string s) => s.Trim(); var upper = (string s) => s.ToUpperInvariant(); var length = (string s) => s.Length; var pipeline = Then(Then(trim, upper), length); Console.WriteLine(pipeline(" hello ")); // 인자 하나를 미리 채워 둔 함수를 돌려줍니다. static Func<int, int> Adder(int n) => x => x + n; var add10 = Adder(10); Console.WriteLine(add10(5)); // 넓은 자리에 좁은 것을 담을 수 있습니다(공변). Func<string> makeString = () => "값"; Func<object> makeObject = makeString; Console.WriteLine(makeObject()); // 좁은 자리에 넓은 것을 담을 수 있습니다(반공변). Action<object> writeObject = o => Console.WriteLine(o); Action<string> writeString = writeObject; writeString("반공변");
코드는 고쳐서 실행해 볼 수 있습니다. 처음 누를 때만 실행기를 내려받느라 잠시 걸립니다. 적은 코드는 서버로 나가지 않습니다.
첫 줄의 5는 " hello " 가 다듬기 → 대문자 →
길이 를 거친 결과입니다. 셋을 각각 부른 것이 아니라
하나로 이어 붙인 함수를 한 번 부른 것입니다.
이미 있는 세 가지
형식을 직접 선언할 일은 거의 없습니다. 표준 라이브러리가 미리 두었습니다.
| 형식 | 모양 | 언제 |
|---|---|---|
| Func<…, TResult> | 인자 0~16개, 반환 있음 | 값을 돌려주는 것 |
| Action<…> | 인자 0~16개, 반환 없음 | 돌려줄 것이 없는 것 |
| Predicate<T> | T 를 받고 bool | Func<T, bool> 과 같습니다. 옛 API 가 사용합니다 |
새 코드에서는 Predicate<T> 대신
Func<T, bool> 를 사용하세요. 하는 일은 같은데
형식이 달라, List<T>.FindAll 에 넘긴 것을 LINQ 에
그대로 넘길 수 없습니다.
받아서 돌려주기
함수를 매개 변수로 받으면 같은 흐름에 다른 규칙을 끼워 넣을 수 있습니다.
// 실패하면 몇 번 더 해 보는 자리. 무엇을 할지는 부르는 쪽이 정합니다. static T Retry<T>(Func<T> work, int times = 3) { for (var i = 1; ; i++) { try { return work(); } catch when (i < times) { } } } var text = Retry(() => File.ReadAllText(path));
반환값으로 돌려주면 인자를 미리 채워 둔 함수를 만들 수
있습니다. 위 최소 예제의 Adder(10) 이 그것입니다.
static Func<string, bool> LongerThan(int n) => s => s.Length > n; var long5 = LongerThan(5); var picked = names.Where(long5);
넓은 자리와 좁은 자리
Func 의 반환 자리는 공변(out T)이고,
Action 의 인자 자리는 반공변(in T)입니다.
규칙은 하나로 읽힙니다. 돌려주는 것은 좁아도 되고, 받는 것은 넓어도
됩니다.
Func<string> a = () => "값"; Func<object> b = a; // 됩니다 // object 를 달라고 했는데 // string 을 주는 것은 문제없습니다
Action<object> a = o => { }; Action<string> b = a; // 됩니다 // string 을 넘길 자리인데 // object 도 받는다면 문제없습니다
반대는 되지 않습니다.
Func<object> 를 Func<string> 에
담으면 문자열을 달라고 한 자리에 아무것이나 올 수 있습니다.
만들면 무엇이 생기는가
람다를 적을 때마다 객체가 생기는 것은 아닙니다. 무엇을 붙잡느냐에 달렸습니다. 같은 람다를 10만 번 만들며 잰 값입니다.
| 람다 | 10만 번 할당 | 1번당 |
|---|---|---|
| 아무것도 붙잡지 않음 | 0바이트 | 한 번 만들고 다시 사용합니다 |
| 필드를 붙잡음(this) | 6,400,000바이트 | 64바이트 |
| 지역 변수를 붙잡음 | 8,800,000바이트 | 88바이트 |
class Box { public int Factor; // 아무것도 붙잡지 않습니다 — 한 번 만들어 담아 둡니다 public Func<int, int> A() => x => x * 3; // 필드를 보므로 this 를 붙잡습니다 — 델리게이트만 생깁니다 public Func<int, int> B() => x => x * Factor; // 지역 변수를 붙잡습니다 — 그것을 담을 것이 하나 더 생깁니다 public Func<int, int> C() { var n = Factor; return x => x * n; } }
가운데 것을 조심하세요. 필드 하나만 보는 것처럼 보이지만
this 를 통째로 붙잡습니다. 그 델리게이트를 어딘가에 오래
담아 두면 객체 전체가 함께 남습니다.
메서드 그룹도 담아 둡니다. C# 11부터입니다.
static Func<int, int> M() => Double; ReferenceEquals(M(), M()); // C# 10 : False C# 11 : True
뜨거운 반복 안에서 델리게이트를 만든다면 붙잡는 것을 없애거나 밖으로 꺼내 담아 두세요. 그렇지 않은 자리에서는 신경 쓰지 않아도 됩니다.
여럿을 묶었을 때
3.4에서 += 로
여럿을 묶었습니다. 값을 돌려주는 것을 묶으면 마지막 것의 값만 남습니다.
Func<int>? f = null; f += () => 1; f += () => 2; f += () => 3; Console.WriteLine(f()); // 3 앞의 둘도 실행되지만 값은 버려집니다
도중에 예외가 발생하면 거기서 멈춥니다. 뒤의 것은 실행되지 않습니다.
Action? g = null; g += () => Console.Write("첫 번째 "); g += () => throw new InvalidOperationException("두 번째에서"); g += () => Console.Write("세 번째 "); try { g(); } catch (Exception ex) { Console.WriteLine(ex.Message); } // 첫 번째 두 번째에서 // "세 번째" 는 나오지 않습니다
전부 실행해야 한다면 GetInvocationList 로 하나씩 부르고
각각 감쌉니다.
foreach (Action one in g.GetInvocationList()) try { one(); } catch { /* 하나가 실패해도 나머지는 부릅니다 */ }
람다는 뺄 수 없습니다
-= 는 같은 인스턴스를 찾아 뺍니다. 똑같이
적은 람다라도 새로 적으면 다른 것입니다.
Action? a = null; a += Say; a += () => Console.Write(""); a -= Say; // 빠집니다 a -= () => Console.Write(""); // 빠지지 않습니다 Console.WriteLine(a?.GetInvocationList().Length); // 1
뺄 생각이라면 변수에 담아 두고 그것을 넘기세요.
Action handler = () => Console.Write(""); b += handler; b -= handler; // 빠집니다
이벤트를 구독한 채로 두면 구독한 쪽이 계속 살아 있습니다. 오래 사는 객체의 이벤트를 짧게 사는 객체가 구독했다면 반드시 빼야 하고, 그러려면 람다가 아니라 변수나 메서드여야 합니다.
델리게이트냐 인터페이스냐
| 이럴 때 | 무엇을 |
|---|---|
| 동작이 하나 | 델리게이트. Where(x => …) 처럼 그 자리에서 적을 수 있습니다 |
| 관련된 동작이 여럿 | 인터페이스. 함께 움직이는 것을 한 형식에 묶습니다 |
| 상태를 함께 들고 다님 | 인터페이스. 담을 자리가 필요하면 그쪽이 읽기 좋습니다 |
| 여러 개를 묶어 부름 | 델리게이트. += 가 이미 그것입니다 |
표준 라이브러리는 둘 다 둡니다. 정렬은
Comparison<T>(델리게이트)와
IComparer<T>(인터페이스)를 함께 받습니다. 그 자리에서
한 줄로 적을 때와, 정렬 규칙에 이름을 붙여 여기저기 사용할 때가 다르기 때문입니다.
부르는 비용
1억 번 불러 잰 값입니다.
| 부르는 방법 | 1억 번 | 1번당 |
|---|---|---|
| 직접 | 20ms | 0.2나노초 |
| 델리게이트 | 170ms | 1.7나노초 |
8배쯤 느리지만 1번에 1.7나노초입니다. 델리게이트를 피할 까닭이 되지 못합니다. 신경 쓸 자리는 부르는 비용이 아니라 뜨거운 반복 안에서 매번 만드는 것입니다.
이 문법, 몇 버전부터 사용할 수 있나요
- 3.0Func · 람다식 · 확장 메서드
- 4.0공변 · 반공변(in · out)
- 9.0static 람다 · 대상 형식 추론
- 11.0메서드 그룹 변환을 담아 둡니다
델리게이트 자체는 1.0부터 있었지만, 값처럼 주고받는 방식이 자리 잡은
것은 3.0 입니다. Func 와 람다식이 함께
들어오면서 형식을 선언하지 않고도 적을 수 있게 되었고, 그 위에 LINQ 가
올라갔습니다. 그 뒤의 변화는 문법이 아니라
덜 만들게 하는 쪽이었습니다. 9.0의
static 람다는 붙잡는 것을 막고, 11.0의 캐싱은
메서드 그룹을 다시 만들지 않습니다.
직접 해보기
어떤 일이든 받아 실행하고 걸린 밀리초를 함께 돌려주는
Measure 를 작성해보세요. 값을 돌려주는 일과 돌려주지
않는 일 모두 받아야 합니다.
아래 Watcher 는 Dispose 에서
구독을 뺀다고 적었는데 실제로는 빠지지 않습니다. 까닭을 설명하고
고쳐보세요.
class Watcher : IDisposable { private readonly Feed _feed; public Watcher(Feed feed) { _feed = feed; _feed.Changed += () => Console.WriteLine("바뀌었습니다"); } public void Dispose() => _feed.Changed -= () => Console.WriteLine("바뀌었습니다"); }
- 함수를 받고 돌려주면 같은 흐름에 다른 규칙을 끼워 넣을 수 있습니다.
- 돌려주는 것은 좁아도 되고(공변), 받는 것은 넓어도 됩니다(반공변).
- 붙잡는 것이 없으면 객체가 생기지 않습니다. 지역 변수를 붙잡으면 1번에 88바이트입니다.
- 필드를 보는 람다는
this를 통째로 붙잡습니다. 오래 담아 두면 객체 전체가 남습니다. - 여럿을 묶으면 마지막 값만 남고, 도중에 예외가 발생하면 거기서 멈춥니다.
-=는 같은 인스턴스를 찾습니다. 람다는 다시 적어서 뺄 수 없습니다.- 부르는 비용은 1번에 1.7나노초입니다. 피할 까닭이 되지 못합니다.
C# 버전별 변경 이력 에서 각 버전이 무엇을 더했는지 볼 수 있습니다.