공부/C#

델리게이트, 멀티캐스트 델리게이트, Func / Action, Predicate, 이벤트, 람다식, 외부변수 캡처

월러비 2025. 8. 18. 19:42

델리게이트

  • 사용자정의 데이터형 중 하나다.

델리게이트의 개념과 기본 사용

  • 델리게이트는 메서드를 참조하는 형식임
    • 델리게이트도 참조형이니 Null 상태가 있다.
    • 메서드 호출이 아니라 참조하는 이유 : 나중에 호출하기 위해 참조하는 것이다.
      • 참조를 하면? : 메서드에 다른 메서드를 매개변수로 호출해서 사용할 수 있게되는 것이다.
  • 메서드의 시그니처(반환 타입과 매개변수)를 정의함
    • 선언한 델리게이트와 동일한 시그니처 만 참조가 가능하다.
    • 참조하는 시그니처가 같다 하더라도 각각 선언한 델리게이트는 다른 데이터형이다.
  • 호출될 메서드를 대신 호출해주는 역할을 함
  • C++의 함수객체와 비슷한 역할이다.

기본 문법과 사용

using System;

// 델리게이트 타입 정의
delegate int Transformer(int x); //델리게이트 선언

class DelegateExample
{
    // 대상 메서드
    static int Square(int x) => x * x;

    static void Main()
    {
        // 델리게이트 인스턴스 생성
        Transformer t = Square; //메서드를 참조할때는 메서드의 이름만 작성한다.

        // 델리게이트를 통한 메서드 호출
        int result = t(3);
        Console.WriteLine(result); // 출력: 9
    }
}

  • 메서드를 참조할때는 메서드의 이름만 작성한다.
  • 굳이 이렇게 하는 이유? : Square를 호출하면 해당 기능만 사용 가능하지만, t에 참조한 메서드를 변경하면 다른 기능을 사용 가능하기 때문이다.
  • 위 예제는 다음과 동일한 의미임:
    • 위와같이 Transformer t = Square;로 작성하더라도, 실제 컴파일러는 암시적으로 아래와 같이 판단해서 동작한다.
    • 코드 작성할때는 위와같이 작성한다. - 동작하는 방식만 알아두라는 것이다.
Transformer t = new Transformer(Square);

델리게이트를 이용한 플러그인 메서드 작성

  • 델리게이트를 매개변수로 받아 실행 시점에 동작을 결정할 수 있음
using System;

class PluginExample
{
    static void Main()
    {
        int[] values = { 1, 2, 3 };
        Transform(values, Square);

        foreach (int i in values)
            Console.Write(i + " "); // 출력: 1 4 9
    }

    static void Transform(int[] values, Transformer t)
    {
        for (int i = 0; i < values.Length; i++)
            values[i] = t(values[i]);
    }

    static int Square(int x) => x * x;
    static int Cube(int x) => x * x * x;
}

delegate int Transformer(int x);

인스턴스 메서드와 정적 메서드 대상

정적 메서드 대상

using System;

class StaticTargetExample
{
    static void Main()
    {
        Transformer t = Test.Square;
        Console.WriteLine(t(10)); // 출력: 100
    }
}

class Test
{
    public static int Square(int x) => x * x;
}

delegate int Transformer(int x);

인스턴스 메서드 대상

using System;

class InstanceTargetExample
{
    static void Main()
    {
        Test test = new Test();
        Transformer t = test.Square; //호출이 아니라 할당
        //Test.Square는 안된다. -> static 으로 선언한것이 아니기 때문이다.
        Console.WriteLine(t(10)); // 출력: 100
    }
}

class Test
{
    public int Square(int x) => x * x;
}

delegate int Transformer(int x);

멀티캐스트 델리게이트

  • 여러 메서드를 하나의 델리게이트에 연결할 수 있음
  • += 연산자로 메서드를 추가하고 -= 연산자로 제거함
using System;

public delegate void ProgressReporter(int percentComplete);

class MulticastExample
{
    static void Main()
    {
        ProgressReporter p = WriteProgressToConsole;
        p += WriteProgressToFile; //추가 참조

        // 두 메서드 모두 호출됨
        Util.HardWork(p);
    }

    static void WriteProgressToConsole(int percentComplete)
        => Console.WriteLine(percentComplete);

    static void WriteProgressToFile(int percentComplete)
        => System.IO.File.WriteAllText("progress.txt",
            percentComplete.ToString()); //txt 파일 생성 함수
}

public class Util
{
    public static void HardWork(ProgressReporter p)
    {
        for (int i = 0; i < 10; i++)
        {
            p(i * 10);
            System.Threading.Thread.Sleep(100); //0.1초 대기
        }
    }
}
//결과 : 0 ~ 90까지 출력
  • 즉, 참조하고있는 함수 전부를 호출하는 것이다.

제네릭 델리게이트와 Func/Action

제네릭 델리게이트

using System;

// 제네릭 델리게이트 정의
public delegate T Transformer<T>(T arg);

class GenericDelegateExample
{
    static void Main()
    {
        int[] values = { 1, 2, 3 };
        Util.Transform(values, Square);

        foreach (int i in values)
            Console.Write(i + " "); // 출력: 1 4 9
    }

    static int Square(int x) => x * x;
}

public class Util
{
    public static void Transform<T>(T[] values, Transformer<T> t)
    {
        for (int i = 0; i < values.Length; i++)
            values[i] = t(values[i]);
    }
}

Func와 Action 델리게이트

  • 미리 선언되어있는 델리게이트 데이터형이다.다.
  • Func: 반환값이 있는 메서드를 참조
    • Func<매개변수 데이터형, 반환형>
    • Func<반환형> - 매개변수 없는 반환형 메서드 참조
    • FUnc<매개변수 , 매개변수, 반환형>
  • Action: 반환값이 없는 메서드를 참조
    • Action<매개변수>
    • Action<매개변수, 매개변수, …>
using System;

class FuncActionExample
{
    static void Main()
    {
        // Func 사용 예
        Func<int, int> square = x => x * x;
        Console.WriteLine(square(3)); // 출력: 9

        // Action 사용 예
        Action<string> print = x => Console.WriteLine(x);
        print("Hello"); // 출력: Hello
    }
}

  • x ⇒ x * x에서 반환형이 안붙는 이유 : Func에서 이미 반환형을 지정했기 때문이다.
  • 델리게이트 이름에 의미를 주고싶은 경우에 델리게이트를 쓰고, 그 이외에 경우에는 Func, Action, Predicate를 쓴다.

Predicate<매개변수>

  • bool 반환이 고정되어있는 Func 델리게이트다.

델리게이트와 인터페이스 비교

  • 다음과 같은 경우 델리게이트가 더 적합함:
    • 인터페이스가 하나의 메서드만 정의할 때
    • 멀티캐스트 기능이 필요할 때
    • 구독자가 인터페이스를 여러번 구현해야 할 때
  • 인터페이스 단점 : 메서드 정의를 위해 인터페이스를 상속한 클래스가 필요하다.
  • 델리게이트 장점 : 함수 없이 ‘람다식’으로 대체가 가능하다.
using System;

// 델리게이트 방식
delegate int Transformer(int x);

// 인터페이스 방식
interface ITransformer
{
    int Transform(int x);
}

class ComparisonExample
{
    static void Main()
    {
        // 델리게이트 사용
        int[] values1 = { 1, 2, 3 };
        Transformer t = Square;
        TransformArray(values1, t);

        // 인터페이스 사용
        int[] values2 = { 1, 2, 3 };
        TransformAll(values2, new Squarer());
    }

    // 델리게이트 버전
    static void TransformArray(int[] values, Transformer t)
    {
		    //들어온 인자를 제곱해서 다시 저장하는 코드
        for (int i = 0; i < values.Length; i++)
            values[i] = t(values[i]);
    }

    static int Square(int x) => x * x;

    // 인터페이스 버전
    static void TransformAll(int[] values, ITransformer t)
    {
        for (int i = 0; i < values.Length; i++)
            values[i] = t.Transform(values[i]);
    }
}

class Squarer : ITransformer
{
    public int Transform(int x) => x * x;
}

델리게이트 호환성(Delegate Compatibility)

타입 호환성

  • 시그니처가 같더라도 서로 다른 델리게이트 타입은 호환되지 않음
  • 단, 델리게이트 인스턴스가 같은 메서드를 참조하면 서로 같다고 판단함
using System;

class TypeCompatibilityExample
{
    static void Main()
    {
        // 서로 다른 델리게이트 타입
        D1 d1 = Method1;
        //D2 d2 = d1;  // 컴파일 에러

        // 명시적 변환은 가능
        D2 d2 = new D2(d1);

        // 같은 메서드를 참조하는 델리게이트 인스턴스는 동일함
        D1 d3 = Method1;
        D1 d4 = Method1;
        Console.WriteLine(d3 == d4);  // 출력: True
    }

    static void Method1() { }
}

delegate void D1();
delegate void D2();

매개변수 호환성

  • 델리게이트의 매개변수는 대상 메서드보다 더 구체적인 타입을 가질 수 있음
    • 즉, 업캐스팅으로 형변환이 가능하다면 매개변수로 받아들일 수 있다.
  • 이를 반공변성(Contravariance)이라 함
using System;

class ParameterCompatibilityExample
{
    static void Main()
    {
        // object를 매개변수로 받는 메서드를
        // string을 매개변수로 받는 델리게이트에 할당 가능
        StringAction sa = ActOnObject;
        sa("hello");  // 출력: hello
    }

    static void ActOnObject(object o) => Console.WriteLine(o);
}

delegate void StringAction(string s);

반환 타입 호환성

  • 델리게이트의 대상 메서드는 더 구체적인 타입을 반환할 수 있음
    • 참조하는 반환형이 델리게이트 반환형으로 업캐스팅 할 수 있으면 참ㅈ가 가능하다는 뜻이다.
  • 이를 공변성(Covariance)이라 함
using System;

class ReturnTypeCompatibilityExample
{
    static void Main()
    {
        // string을 반환하는 메서드를
        // object를 반환하는 델리게이트에 할당 가능
        ObjectRetriever o = RetrieveString;
        object result = o();
        Console.WriteLine(result);  // 출력: hello
    }

    static string RetrieveString() => "hello";
}

delegate object ObjectRetriever();

제네릭 델리게이트 타입 매개변수 가변성

  • 반환값에만 사용되는 타입 매개변수는 공변성(out)을 표시할 수 있음
  • 매개변수에만 사용되는 타입 매개변수는 반공변성(in)을 표시할 수 있음
using System;

class GenericVarianceExample
{
    static void Main()
    {
        // 공변성(out) 예제
        Func<string> strFunc = () => "test";
        Func<object> objFunc = strFunc;  // 가능

        // 반공변성(in) 예제
        Action<object> objAction = (object o) => Console.WriteLine(o);
        Action<string> strAction = objAction;  // 가능

        // 실행
        Console.WriteLine(objFunc());  // 출력: test
        strAction("hello");  // 출력: hello
    }
}

이벤트(event)

이벤트 개념

  • 이벤트는 특정 상황이 발생했음을 다른 객체에게 알리는 기능임
  • 이벤트를 사용할 때는 두 가지 주요 역할이 있음:
    • 브로드캐스터(Broadcaster): 이벤트를 포함하는 타입으로, 이벤트 발생을 결정함
    • 구독자(Subscriber): 이벤트를 수신하는 측으로, 이벤트 발생 시 실행될 메서드를 등록함

이벤트 선언

  • 이벤트는 delegate 필드 앞에 event 키워드를 붙여 선언함
  • 예제:
// 델리게이트 정의
public delegate void PriceChangedHandler(decimal oldPrice, decimal newPrice);

public class Broadcaster
{
    // 이벤트 선언
    public event PriceChangedHandler PriceChanged;
}

이벤트 내부 동작

  • 이벤트가 선언되면 컴파일러는 다음과 같은 작업을 수행함:
    • 작성은 위와같이 한다.
// 다음 코드는 컴파일러가 자동으로 생성하는 코드를 보여줌
private PriceChangedHandler priceChanged; // private delegate 필드

public event PriceChangedHandler PriceChanged
{
    add { priceChanged += value; }    // 이벤트 구독
    remove { priceChanged -= value; }  // 이벤트 구독 취소
}

실전 예제: 주식 가격 변동 알림

  • 주식 가격이 변경될 때 이벤트를 발생시키는 예제:
// 주식 클래스 정의
public class Stock
{
    string symbol;
    decimal price; //0으로 초기화된다.

    public Stock(string symbol) => this.symbol = symbol;

    // 이벤트 선언
    public event PriceChangedHandler PriceChanged;

    public decimal Price
    {
        get => price;
        set
        {
            if (price == value) return;
            decimal oldPrice = price;
            price = value;
            if (PriceChanged != null)
                PriceChanged(oldPrice, price);
        }
    }
}

// 사용 예제
Stock stock = new Stock("MSFT");
//매개변수를 받아서 우측 기능을 동작하는 '람다식'이다.
stock.PriceChanged += (oldPrice, newPrice) =>
    Console.WriteLine($"가격 변동: {oldPrice} -> {newPrice}");

stock.Price = 30.00m; // 이벤트 발생

  • event가 없으면 그냥 public으로 사용하는것과 같지만, event가 붙으면 외부에서는 += 으로만 사용가능하고 그 이외에 사용은은 event가 선언된 클래스 내부에서만 사용가능하게 된다.
  • 이벤트 확인 ‘null’로 체크한다.

표준 이벤트 패턴

  • .NET에서는 이벤트에 대한 표준 패턴이 있음
  • EventArgs 클래스를 상속하여 이벤트 데이터를 전달함:
// 이벤트 데이터 클래스
public class PriceChangedEventArgs : EventArgs
{
    public readonly decimal LastPrice;
    public readonly decimal NewPrice;

    public PriceChangedEventArgs(decimal lastPrice, decimal newPrice)
    {
        LastPrice = lastPrice;
        NewPrice = newPrice;
    }
} 

// 표준 이벤트 패턴 적용 예제
public class Stock
{
    string symbol;
    decimal price;

    public Stock(string symbol) => this.symbol = symbol;

    // EventHandler<T> 사용
    public event EventHandler<PriceChangedEventArgs> PriceChanged;

    protected virtual void OnPriceChanged(PriceChangedEventArgs e)
    {
        PriceChanged?.Invoke(this, e);
    }

    public decimal Price
    {
        get => price;
        set
        {
            if (price == value) return;
            decimal oldPrice = price;
            price = value;
            OnPriceChanged(new PriceChangedEventArgs(oldPrice, price));
        }
    }
}

// 사용 예제
Stock stock = new Stock("MSFT");
stock.PriceChanged += (sender, args) =>
{
    if ((args.NewPrice - args.LastPrice) / args.LastPrice > 0.1m)
        Console.WriteLine("주식 가격 10% 이상 상승!");
};

stock.Price = 27.10m;  // 가격 설정
stock.Price = 31.59m;  // 가격 변경으로 이벤트 발생

  • Invoke : Null이 아니라면 참조하는 메서드를 호출한다.
    • 델리게이트도 같이 사용할 수 있다.

이벤트 접근자

  • 이벤트의 add와 remove 접근자를 직접 구현할 수 있음
  • 예제:
private EventHandler priceChanged;
public event EventHandler PriceChanged
{
    add { priceChanged += value; }
    remove { priceChanged -= value; }
}

이벤트 수정자

  • 이벤트도 메서드처럼 virtual, override, abstract, sealed 수정자 사용 가능
  • static 이벤트도 선언 가능
  • 예제:
public class Foo
{
    public static event EventHandler<EventArgs> StaticEvent;
    public virtual event EventHandler<EventArgs> VirtualEvent;
}

람다식

람다 표현식의 기본 개념

  • 람다 표현식(Lambda Expression)은 무명 메서드를 작성하는 간단한 방법임
  • 대리자(Delegate) 인스턴스를 생성할 때 주로 사용함
  • 컴파일러는 람다 표현식을 다음 중 하나로 변환함:
    • 대리자 인스턴스
    • 식 트리(Expression Tree)

람다 표현식의 기본 구문

  • 기본 형식: (매개변수) => 식-또는-문장블록
    • ⇒ : 람다 연산자 라고 부른다.
  • 매개변수가 하나이고 타입 유추가 가능한 경우 괄호 생략 가능함
// 기본 예제
Transformer t = x => x * x; // 매개변수 x를 제곱하여 반환
Console.WriteLine(t(3));    // 출력: 9

// 대리자 타입 정의
delegate int Transformer(int i);

람다 식과 문장 블록

  • 단일 식(Expression)으로 작성 가능한 경우:
x => x * x

  • 문장 블록(Statement Block)이 필요한 경우:
x => { return x * x; }

Func와 Action 대리자 활용

  • 람다 표현식은 Func와 Action 대리자와 함께 자주 사용함
  • Func: 반환 값이 있는 메서드를 대리함
  • Action: 반환 값이 없는 메서드를 대리함
// Func 사용 예제
Func<int, int> square = x => x * x;
Console.WriteLine(square(3));  // 출력: 9

// Action 사용 예제
Action<string> print = message => Console.WriteLine(message);
print("Hello");  // 출력: Hello

// 두 개의 매개변수를 받는 예제
Func<string, string, int> totalLength = (s1, s2) => s1.Length + s2.Length;
int total = totalLength("hello", "world");  // total은 10

매개변수와 반환 타입 명시

  • 컴파일러가 타입을 유추할 수 없는 경우 명시적으로 지정해야 함
// 매개변수 타입 명시
Func<int, int> square = (int x) => x * x;

// C# 10부터는 반환 타입도 명시 가능
var square = int (int x) => x * x;

외부 변수 캡처

  • 람다 표현식은 정의된 범위의 외부 변수를 사용할 수 있음
    • 해당 람다식이 있는 코드영역에서 접근할 수 있는 지역변수, 멤버 변수 전부 접근 가능하다.
  • 캡처된 변수는 람다 표현식이 실행될 때 평가됨
int factor = 2;
Func<int, int> multiplier = n => n * factor;
Console.WriteLine(multiplier(3));  // 출력: 6

factor = 10;
Console.WriteLine(multiplier(3));  // 출력: 30

  • 람다 표현식은 캡처된 변수를 직접 수정할 수 있음:
    • 왜 직접 수정할수있냐? : 레퍼런스에 접근하는것이기 때문이다.
int seed = 0;
Func<int> natural = () => seed++;
Console.WriteLine(natural());  // 출력: 0
Console.WriteLine(natural());  // 출력: 1
Console.WriteLine(seed);       // 출력: 2

  • 캡처된 변수는 대리자의 수명까지 연장됨:
    • 캡쳐된 변수는 스코프가 끝나도 스택 영역에서 지역 변수가 사라지지 않는다.
    • 왜? : 리턴을 참조하고있기 때문에 지역변수가 사라지면 에러가 발생하기 때문이다.
Func<int> natural = Natural();
Console.WriteLine(natural());  // 출력: 0
Console.WriteLine(natural());  // 출력: 1

static Func<int> Natural()
{
    int seed = 0;
    return () => seed++;  // 클로저 반환
}

  • 람다 식 내부에서 선언된 변수는 매 호출마다 새로 생성됨:
    • 캡쳐와는 다른것이다.
Func<int> natural = Natural();
Console.WriteLine(natural());  // 출력: 0
Console.WriteLine(natural());  // 출력: 0  // 매번 0 출력

static Func<int> Natural()
{
    return () => { int seed = 0; return seed++; };
}

반복문에서의 변수 캡처 주의사항

  • for 루프의 반복 변수를 캡처할 때는 변수가 공유된다는 점에 주의해야 함
  • 다음 예제는 의도와 다르게 동작할 수 있음:
Action[] actions = new Action[3];

for (int i = 0; i < 3; i++)
    actions[i] = () => Console.Write(i);

foreach (Action a in actions)
    a();  // 출력: 333

  • i가 레퍼런스로 들어가지기 때문에 i는 같은 값을 공유하게 된다.
    • i도 없어져야하지만 람다식에 캡쳐되있기 때문에 사라지지 않고 남아있는다.
  • 해결책: 루프 내에서 지역 변수를 새로 선언하여 사용
Action[] actions = new Action[3];

for (int i = 0; i < 3; i++)
{
    int loopScopedi = i;
    actions[i] = () => Console.Write(loopScopedi);
}

foreach (Action a in actions)
    a();  // 출력: 012

정적 람다 식 (C# 9)

  • static 키워드를 사용하여 외부 변수 캡처를 방지할 수 있음
  • 성능 최적화가 필요한 경우 유용함
// 정적 람다 식
Func<int, int> multiplier = static n => n * 2;

// 다음은 컴파일 오류 발생
int factor = 2;
Func<int, int> error = static n => n * factor; // 컴파일 오류

람다 표현식과 지역 메서드 비교

재귀 호출

  • 지역 메서드는 자연스러운 재귀 호출이 가능함
  • 람다 식은 재귀 구현이 복잡함
// 지역 메서드로 재귀 구현 - 자연스러움
int Factorial(int n)
{
    return n <= 1 ? 1 : n * Factorial(n - 1);
}

// 람다 식으로 재귀 구현 - 복잡함
Func<int, int> factorial = null;
factorial = n => n <= 1 ? 1 : n * factorial(n - 1);

타입 선언 방식

  • 지역 메서드는 일반 메서드처럼 직관적으로 선언함
  • 람다 식은 대리자 타입을 명시해야 함
// 람다 식 - 대리자 타입 명시 필요
Func<int, bool> predicate = x => x > 0;
Action<string> printer = s => Console.WriteLine(s);

// 지역 메서드 - 일반적인 메서드 선언
bool IsPositive(int x) => x > 0;
void Print(string s) => Console.WriteLine(s);

성능 특성

  • 지역 메서드:
    • 대리자 생성 비용이 없음
    • 직접 호출로 인한 성능상 이점이 있음
    • 변수 캡처시 더 효율적인 코드 생성됨
  • 람다 식:
    • 대리자 인스턴스 생성 비용 발생
    • 간접 호출로 인한 오버헤드 존재
void ProcessData()
{
    var items = new List<int> { 1, 2, 3 };

    // 람다 식 - 대리자 생성 및 간접 호출
    var filtered = items.Where(x => x > 1);

    // 지역 메서드 - 직접 호출
    bool IsGreaterThanOne(int x) => x > 1;
    var filtered2 = items.Where(IsGreaterThanOne);
}

적합한 사용 시기

  • 람다 식이 유리한 경우:
    • LINQ 쿼리 작성시
    • 대리자를 매개변수로 받는 메서드 호출시
    • 간단한 일회성 함수 필요시
  • 지역 메서드가 유리한 경우:
    • 재귀 호출이 필요한 경우
    • 여러 곳에서 재사용되는 로직 필요시
    • 성능이 중요한 경우