멈추지 않고 끈질기게

[C#] 대리자(Delegate) 본문

C#

[C#] 대리자(Delegate)

sam0308 2023. 2. 9. 12:03

※ 해당 포스팅은 개인의 공부 정리용 글입니다. 틀린 내용이 있다면 추후 수정될 수 있습니다.

※ 해당 포스팅은 .Net 5.0 버전을 기준으로 작성되었습니다.

 

 

이번 포스팅에서는 C#의 대리자(Delegator) 개념에 대해 알아보겠습니다.

 

1. 대리자(Delegate)의 정의

 프로그래밍에서는 데이터를 변수에 저장하여 함수에 전달하는 등 자유롭게 사용합니다. 이 개념을 함수에 적용시킨 것이 대리자입니다. 대리자를 선언한 뒤 해당 대리자 타입의 인스턴스를 함수로 초기화하면, 해당 대리자는 함수의 주소를 참조하게 됩니다. 또한 대리자 인스턴스는 다른 함수의 매개변수로 사용할 수 있습니다(매개변수로 사용되는 함수를 콜백 함수(callback function)라 합니다).

 대표적인 예가 이전 포스팅에서 알아봤던 Sort() 함수의 매개변수로 사용하는 경우입니다. Sort() 함수는 기본적으로 해당 타입이나 클래스의 CompareTo() 함수를 이용해 정렬을 실행하지만, 매개변수로 Comparison<T> 대리자를 전달하여 원하는 조건으로 정렬할 수 있었습니다. 

class Program
{
    static void Main(string[] args)
    {
        List<int> list = new List<int> { 2, 5, 4, 1, 3 };
        list.Sort(NewCompare); //함수를 매개변수로 전달

        foreach (var item in list)
            Console.Write(item + " ");
    }

    //내림차순 정렬용 함수
    static int NewCompare(int a, int b)
    {
        return b > a ? 1 : -1;
    }
}

그림 1. Sort() 함수의 매개변수

 

2. 대리자의 선언 및 초기화

대리자는 다음과 같이 3가지 방법으로 초기화할 수 있습니다.

class Program
{
    //델리게이터 선언
    delegate void PrintString(string input);

    static void Main(string[] args)
    {
        //함수명을 통한 초기화
        PrintString p1 = PrintSomething;

        //무명 델리게이터를 이용한 초기화
        PrintString p2 = delegate (string s) { Console.Write(s); };

        //람다식을 이용한 초기화
        PrintString p3 = (s) => { Console.WriteLine(s); };

        p1("2023년 ");
        p2("2월 ");
        p3("9일");
    }

    static void PrintSomething(string input)
    {
        Console.Write(input);
    }
}

그림 2. 대리자의 선언과 초기화

 대리자는 클래스와 같은 자료형으로, 먼저 형식을 지정해 줄 필요가 있습니다. 상기 코드에서는 string 타입의 매개변수를 하나 받고, 반환형은 없는 printString 대리자를 선언했습니다. 메인 함수에서 매개변수와 반환형이 일치하는 함수의 이름 PrintSomething을 통해 p1 을 초기화하였고, 무명 델리게이터를 통해 p2를 초기화, 그리고 람다식을 통해 p3를 초기화하였습니다.

 

 무명 델리게이터를 이용하면 함수를 따로 선언하지 않고도 전달할 수 있습니다. 무명 델리게이터는 상기 코드와 같이

delegate(매개변수1, 매개변수2, ...) { 함수 내용 선언 }

의 형식으로 선언합니다. 물론 델리게이터와 매개변수 및 반환형은 일치해야 합니다. 예시처럼 함수의 내용이 간단하고, 다른 곳에서 사용할 일이 없다면 무명 델리게이터를 이용할 수 있습니다.

 

 람다식을 이용하면 무명 델리게이터보다도 짧게 선언하여 전달할 수 있습니다. 람다식은 상기 코드와 같이

(매개변수1, 매개변수2, ...) => { 함수 내용 선언 }

의 형식으로 선언합니다. 이 때, 매개변수들의 타입 조차도 선언하지 않습니다. 상기 코드에서는 string 타입의 매개변수를 하나 받는 대리자를 초기화하는데 사용했기 때문에, 매개변수의 갯수만 맞춰주면 됩니다. 

 

 

다른 클래스의 함수에 매개변수로 전달하는 경우 다음과 같이 사용할 수 있습니다.

class DelegateTest
{
    //매개변수로 받을 함수 타입의 대리자 선언
    public delegate void Print(string s);

    public void PrintInput(Print print, string s)
    {
        //전달받은 대리자 실행
        print(s);
    }
}

class Program
{
    static void Main(string[] args)
    {
        DelegateTest dt = new DelegateTest();
        //함수명으로 전달
        dt.PrintInput(PrintSomething, "2023년 ");
        //무명 델리게이터로 전달
        dt.PrintInput(delegate (string s) { Console.WriteLine(s); }, "2월 ");
        //람다식으로 전달
        dt.PrintInput((s) => { Console.WriteLine(s); }, "9일");
    }

    static void PrintSomething(string s)
    {
        Console.WriteLine(s);
    }
}

그림 3. 대리자를 통한 함수 전달

 

 

3. 델리게이터의 연산

델리게이터 변수 끼리는 연산, 정확히는 덧셈과 뺄셈이 가능합니다.

class Program
{
    delegate void PrintString(string input);

    static void Main(string[] args)
    {
        PrintString p1 = (s) => { Console.WriteLine($"'{s}'을(를) 입력했습니다."); };
        PrintString p2 = (s) => { Console.WriteLine($"'{s}'는 어떤 의미인가요?"); };

        Console.Write("아무 단어나 입력해주세요: ");
        string input = Console.ReadLine();

        //p1, p2 실행
        p1 += p2;
        p1(input);

        Console.WriteLine();
        //p1 실행
        p1 -= p2;
        p1(input);
    }
}

그림 4. 대리자의 연산

상기 코드는 printString 대리자 변수 p1, p2를 람다식을 통해 초기화하고, 사용자 입력을 받은 후 출력하는 내용입니다. 처음에 p1에 p2를 더한 다음 실행하니 p1, p2가 모두 실행되었고, 그다음에 다시 p1 + p2에서 p2를 뺀 후 실행하니 p1만 실행된 모습을 볼 수 있습니다. 이렇듯 같은 형식의 대리자 끼리는 연산이 가능하기 때문에, 같은 형식의 대리자끼리 덧셈으로 묶어서 전달하는 등 상황에 따라 편리하게 사용할 수 있습니다.

 

 

4. Action과 Func

C#에는 대리자를 다르게 선언하는 방법도 있습니다. Action은 반환형이 없는 대리자를, Func는 반환형을 갖는 대리자를 선언할 때 이용할 수 있습니다.

class Program
{
    static void Main(string[] args)
    {
        Action<string> PrintString = (s) => { Console.WriteLine(s); };
        Func<int, bool> IsOdd = (num) => { return (num & 1) == 1; };

        PrintString("217은 홀수입니까?");
        Console.WriteLine(IsOdd(217));
    }
}

그림 5. Action과 Func

Action은 다음과 같이 선언할 수 있습니다.

Action<T1, T2, ...> (변수이름)

매개변수의 수만큼 매개변수 타입을 <>안에 선언하고, 매개변수를 받지 않을 경우에는 <> 자체를 생략하면 됩니다. 상기 코드에서는 string  타입의 매개변수를 받고 출력하는 람다식으로 초기화하였습니다.

 

Func는 다음과 같이 선언할 수 있습니다.

Func<T1, T2, ..., TResult> (변수이름)

Action과 마찬가지로 매개변수 수만큼 타입을 <>안에 선언하고, 추가로 반환형을 마지막에 선언합니다. 매개변수를 받지 않는 경우에도 최소한 반환형은 선언해야 합니다. 상기 코드에서는 int 타입 매개변수를 받고, 해당 숫자가 홀수인지 판별하여 bool 값을 반환하는 람다식으로 초기화하였습니다. Action이나 Func 모두 초기화 하는 방식은 기존의 대리자를 초기화하는 방법과 동일합니다.

 

그렇다면 기존의 delegate를 두고 Action과 Func는 어떤 경우에 사용해야 할까요?

public class DelegateTest
{
    public delegate void Repeat();

    public long CheckTime(Repeat repeat)
    {
        Stopwatch watch = new Stopwatch();
        watch.Start();
        repeat();
        watch.Stop();

        return watch.ElapsedMilliseconds;
    }
}

 상기 클래스에는 매개변수를 받지않고 반환형도 없는 타입의 대리자와, 해당 대리자 타입의 함수를 전달받아 작업을 수행하는데 걸린 시간을 반환하는 CheckTime() 함수를 선언했습니다. 그런데 여기서 CheckTime() 함수를 매개 변수 타입에 따라 여러개 오버로딩 하려면 함수의 개수만큼 대리자를 선언해야 할 것입니다. 하지만 Action이나 Func를 사용하면 그럴 필요가 없습니다.

public class DelegateTest
{
    public long CheckTime(Action act)
    {
        Stopwatch watch = new Stopwatch();
        watch.Start();
        act();
        watch.Stop();

        return watch.ElapsedMilliseconds;
    }

    public long CheckTime(Func<int, string> func, int num, out string result)
    {
        Stopwatch watch = new Stopwatch();
        watch.Start();
        result = func(num);
        watch.Stop();

        return watch.ElapsedMilliseconds;
    }
}

 기존의 매개변수, 반환형이 모두 없는 타입의 CheckTime() 함수는 대리자 선언 없이 Action을 받는 형식으로 선언하였습니다. 또한  int 타입 매개변수를 받고 string 타입을 반환하는 함수를 매개변수로 받는 CheckTime() 함수도 Func<int, string>을 사용하여 추가적인 대리자 선언 없이 간단하게 오버로딩하였습니다.

 

또한 Action이나 Func는 유니티에서도 활용할 수 있습니다.

public class ObjectManager : MonoBehaviour
{
    public static Func<string, GameObject> makeObj;
    public static Action<int> loadObjects;

    void Awake()
    {
        makeObj = (a) => { return MakeObj(a); };
        loadObjects = (a) => {  Generate(); LoadObjects(a); };
    }

 상기 코드는 제 유니티 포트폴리오의 스크립트 중 일부를 발췌한 것입니다. 유니티에서 Monobehaviour를 상속받은 클래스는 오브젝트와 함께 존재해야 하고, 오브젝트가 다른 씬에 존재하는 오브젝트를 직접 참조할 수 없기 때문에 다른 씬에서 해당 클래스에 접근하기 어려운 문제가 있습니다 . 이런 경우 싱글톤으로 구현하여 해결할 수도 있으나, 해당 클래스의 일부 기능만 다른 씬에서 접근하고 싶은 경우 위와 같이 Action 또는 Func를 public static으로 선언하여 해결할 수도 있습니다. 상기 코드처럼 선언해두면 이제 어디에서든 ObjectManager.makeObj를 통해 MakeObj() 함수를 사용할 수 있습니다.

'C#' 카테고리의 다른 글

[C#] 가비지 컬렉터(Garbage Collector)  (0) 2023.02.20
[C#] Linq 구문  (0) 2023.02.18
[C#] IComparable 인터페이스, Comparison<T> 대리자  (0) 2023.01.31
[C#] 딕셔너리(Dictionary)  (0) 2023.01.27
[C#] 큐(Queue)와 스택(Stack)  (0) 2023.01.26