C#LAB
C# 3.4 · 3부. 중급 문법

델리게이트와 이벤트

메서드를 값처럼 넘기고, 일이 일어났을 때 알립니다.

예상 학습 시간 16분 실행 단추가 있는 예제는 고쳐서 실행해 볼 수 있습니다 난이도 중급
개념 설명

메서드를 값처럼 다루기

숫자는 int 변수에, 글자는 string 변수에 담습니다. 그러면 메서드는 어디에 담을까요. 담을 수 있다면 매개 변수로 넘기거나 속성에 두거나 목록에 모을 수 있을 텐데요.

델리게이트(delegate)가 그것입니다. 메서드를 담는 형식이며, 담을 메서드가 무엇을 받고 무엇을 돌려주는지를 미리 정해 둡니다. 모양이 맞는 메서드만 담기므로 잘못 담으면 컴파일 오류입니다.

// int 둘을 받아 int 를 돌려주는 메서드를 담는 형식
delegate int Calc(int a, int b);

이것이 있으면 "무엇을 할지" 를 부르는 쪽이 정하게 할 수 있습니다. 3.3Where(p => p.Price <= 3000) 가 바로 그 방식이었습니다 — Where 는 거르는 절차만 알고, 무엇이 조건인지는 넘겨받습니다.

그리고 델리게이트에 event 를 붙이면 일이 일어났을 때 알리는 자리가 됩니다. 알림을 보내는 쪽은 누가 받는지 모르고, 받는 쪽은 언제 일어나는지 모릅니다. 둘이 서로를 몰라도 되게 떼어 놓는 것이 이 문법의 값어치입니다.

최소 예제 · 편집 가능

메서드를 담고 바꿔 담기

아래 코드는 브라우저 안에서 실제로 실행됩니다. 고쳐서 눌러 보셔도 됩니다.

Program.cs
// 델리게이트 변수에 메서드 이름을 담습니다. 괄호를 적지 않습니다 —
// 부르는 것이 아니라 메서드 자체를 담는 것이기 때문입니다.
Calc op = Add;
Console.WriteLine(op(3, 4));      // 7

op = Multiply;                 // 담긴 것을 바꿉니다
Console.WriteLine(op(3, 4));      // 12

// 형식을 직접 만들지 않아도 되는 것들이 준비되어 있습니다
Func<int, int, int> f = Add;         // 돌려주는 것이 있을 때
Action<string> log = Console.WriteLine;  // 없을 때

Console.WriteLine(f(10, 20));
log("메서드를 값처럼 넘겼습니다");

static int Add(int a, int b) => a + b;
static int Multiply(int a, int b) => a * b;

delegate int Calc(int a, int b);
출력
7 12 30 메서드를 값처럼 넘겼습니다

코드는 고쳐서 실행해 볼 수 있습니다. 처음 누를 때만 실행기를 내려받느라 잠시 걸립니다. 적은 코드는 서버로 나가지 않습니다.

op = Add;괄호가 없다는 점을 보세요. Add(3, 4) 는 부른 결과인 7 이고, Add메서드 자체입니다. 담아 두었다가 나중에 op(3, 4) 로 부릅니다.

상세 사용법

형식을 직접 만들지 않아도 됩니다

delegate 로 형식을 선언하는 것은 C# 1.0의 방법이고, 지금은 모양별로 미리 준비된 제네릭 델리게이트를 그대로 사용합니다. 3.1의 형식 매개 변수로 받을 것과 돌려줄 것을 적습니다.

형식모양예시
Action받지 않고 돌려주지 않음Action save = Flush;
Action<T>받되 돌려주지 않음Action<string> log = Console.WriteLine;
Func<TResult>받지 않고 돌려줌Func<int> now = GetCount;
Func<T, TResult>받고 돌려줌Func<int, bool> even = n => n % 2 == 0;
Predicate<T>받아서 참·거짓Predicate<int> even = n => n % 2 == 0;

Func 는 마지막 형식 매개 변수가 돌려주는 것입니다 — Func<int, int, int>int 둘을 받아 int 를 돌려줍니다. 매개 변수는 16개까지 받습니다. Predicate<T>Func<T, bool> 과 하는 일이 같아 새 코드에서는 잘 사용하지 않습니다.

분기로 적을 때와 무엇이 다른가

안에서 분기하면
static int Count(int[] items, string kind) {
    var n = 0;
    foreach (var it in items) {
        if (kind == "짝수" && it % 2 == 0) n++;
        else if (kind == "양수" && it > 0) n++;
    }
    return n;
}

// 조건이 늘 때마다 이 메서드를 고칩니다
조건을 넘겨받으면
static int Count(int[] items, Func<int, bool> match) {
    var n = 0;
    foreach (var it in items)
        if (match(it)) n++;
    return n;
}

Count(items, n => n % 2 == 0);
Count(items, n => n > 0);
// 조건이 늘어도 이 메서드는 그대로입니다

오른쪽이 3.3Count·Where 가 실제로 만들어진 방식입니다. 세는 절차와 무엇을 셀지가 갈라져 있어서, 조건이 늘어도 메서드는 그대로입니다.

여럿을 묶을 수 있습니다

델리게이트는 +=여러 메서드를 묶어 담을 수 있습니다. 부르면 담긴 순서대로 전부 실행됩니다.

Action notify = () => Console.WriteLine("로그를 남깁니다");
notify += () => Console.WriteLine("메일을 보냅니다");
notify += () => Console.WriteLine("화면을 새로 그립니다");

notify();
// 로그를 남깁니다
// 메일을 보냅니다
// 화면을 새로 그립니다

notify -= …;   // 빼는 것도 됩니다

돌려주는 것이 있으면 마지막 하나만 남습니다. 전부 실행되기는 하지만 앞의 결과는 버려집니다.

Func<int> many = () => 1;
many += () => 2;
many += () => 3;

Console.WriteLine(many());   // 3 — 앞의 1 과 2 는 버려집니다

그래서 여럿을 묶을 자리에는 Action 을 사용합니다. 결과를 모아야 한다면 목록을 넘겨 담게 하거나 GetInvocationList 로 하나씩 부르는 방법이 있지만, 그때는 델리게이트가 맞는 도구인지 다시 생각해 보는 편이 낫습니다.

event — 밖에서 손대지 못하게

알림을 보내는 자리를 델리게이트 필드로 그냥 두면 밖에서 무엇이든 할 수 있습니다. 통째로 null 을 담아 다른 사람이 등록해 둔 것을 지우거나, 아무 때나 직접 불러 일어나지도 않은 일을 알릴 수 있습니다.

class Clock {
    public event Action<int>? Ticked;   // event 를 붙였습니다

    public void Run() {
        for (var i = 1; i <= 3; i++)
            Ticked?.Invoke(i);     // 안에서는 부를 수 있습니다
    }
}

// 밖에서는
clock.Ticked += n => Console.WriteLine($"{n}초");   // 됩니다

clock.Ticked = null;      // CS0070
clock.Ticked?.Invoke(1);  // CS0070
// += 또는 -= 의 왼쪽에만 사용할 수 있습니다

event 를 붙이면 밖에서는 +=-= 만 됩니다. 등록과 해제는 누구나, 그러나 지우는 것과 알리는 것은 그 형식 안에서만 입니다. 붙이는 낱말 하나로 이 규칙이 생깁니다.

?.Invoke(i) 의 물음표를 빠뜨리지 마세요. 아무도 등록하지 않았으면 null 이라, 그냥 부르면 예외가 발생합니다. ?.null 이면 아무 일도 하지 않고 넘어갑니다(C# 6.0).

이름 짓는 관례가 있습니다. 일어난 뒤에 알리는 것은 과거형 (Ticked·Saved·Closed), 일어나기 전에 알려 막을 기회를 주는 것은 진행형 (Closing·Saving)입니다. 닷넷 라이브러리는 EventHandler<TEventArgs> 라는 델리게이트를 사용하는데, 보내는 쪽(object? sender)과 함께 넘기는 관례입니다.

버전 배지

이 문법, 몇 버전부터 사용할 수 있나요

1.0
2.0
3.0
6.0
7.0
8.0
9.0
11.0
14.0
  • 1.0delegate · event · 여럿 묶기
  • 3.0람다식으로 그 자리에서 넘기기 · Func · Action
  • 6.0?.Invoke — null 이면 넘어가기
C# 1.0+

델리게이트와 이벤트는 C# 1.0부터 있었지만, 그때는 넘길 메서드를 어딘가에 이름을 붙여 따로 만들어야 했습니다. 조건 하나를 넘기려고 메서드를 선언하는 것이 번거로워 잘 사용되지 않다가, C# 3.0의 람다식이 그 자리에서 적을 수 있게 하면서 널리 퍼졌습니다. 3.3의 LINQ가 그 위에 세워진 것이고, 3.5에서 람다를 자세히 다룹니다.

실습 문제

직접 해보기

1. 조건을 넘겨받아 세기 난이도 하

배열과 조건을 받아 맞는 것이 몇 개인지 돌려주는 CountBy 를 만들어보세요. 짝수 개수와 50 이상인 것의 개수를 같은 메서드로 세어 봅니다.

조건은 int 를 받아 참·거짓을 돌려주므로 Func<int, bool> 입니다. 부를 때는 보통 메서드처럼 match(it) 로 적습니다.
int[] numbers = [12, 7, 50, 33, 88, 4]; Console.WriteLine(CountBy(numbers, n => n % 2 == 0)); // 4 Console.WriteLine(CountBy(numbers, n => n >= 50)); // 2 static int CountBy(int[] items, Func<int, bool> match) { var n = 0; foreach (var it in items) if (match(it)) n++; return n; } // LINQ 의 Count(조건) 이 이렇게 만들어져 있습니다.
2. 잔액이 바뀌면 알리기 난이도 중

계좌 클래스를 만들고 잔액이 바뀔 때 알리는 이벤트를 두세요. 받는 쪽 둘을 등록해 하나는 기록을 남기고, 하나는 잔액이 0 아래로 내려갈 때만 경고하게 해봅니다.

알리기만 하면 되므로 Action<int>event 를 붙입니다. 등록한 쪽이 없을 수 있으니 부를 때 ?.Invoke 입니다.
var account = new Account(); account.Changed += balance => Console.WriteLine($"잔액 {balance:N0}원"); account.Changed += balance => { if (balance < 0) Console.WriteLine(" 잔액이 모자랍니다"); }; account.Deposit(10000); account.Withdraw(3000); account.Withdraw(9000); // 잔액 10,000원 // 잔액 7,000원 // 잔액 -2,000원 // 잔액이 모자랍니다 class Account { public event Action<int>? Changed; public int Balance { get; private set; } public void Deposit(int amount) => Apply(amount); public void Withdraw(int amount) => Apply(-amount); private void Apply(int amount) { Balance += amount; Changed?.Invoke(Balance); } }
요약
  • 델리게이트는 메서드를 담는 형식입니다. 담을 때는 괄호를 적지 않습니다.
  • 형식을 직접 선언하기보다 Action·Func 를 사용합니다. Func마지막 형식 매개 변수가 돌려주는 것입니다.
  • += 로 여럿을 묶으면 순서대로 전부 실행되지만, 돌려주는 값은 마지막 것만 남습니다.
  • event 를 붙이면 밖에서 +=·-= 만 됩니다. 지우는 것과 알리는 것은 그 형식 안에서만입니다.
  • 알릴 때는 ?.Invoke 입니다. 등록한 쪽이 없으면 null 이기 때문입니다.

C# 버전별 변경 이력 에서 각 버전이 무엇을 더했는지 볼 수 있습니다.