XR 플밍 - 4. 객체지향 프로그래밍과 데이터 구조 - (3-1) Delegate, Event, 무명메서드와 람다식 (3/27)

이형원·2025년 3월 27일
0

XR플밍

목록 보기
24/215

1. 델리게이트(Delegate)

우리는 자료형에 관해서 배워 본 바가 있다.

int intValue = 10;
float floatValue = 1.2f;
bool isBool = true;

이와 같이 (자료형) (변수) = (데이터); 의 형식으로 데이터를 저장하였다.
하지만 여기서, 함수를 이런 형태로 저장할 수 없을까?

이걸 가능하게 하는 것이 델리게이트(Delegate) 이다.

1.1) 델리게이트의 정의

대리자(Delegate)

-특정 매개 변수 목록 및 반환 형식이 있는 함수에 대한 참조
-대리자 인스턴스를 통해 함수를 호출할 수 있음

델리게이트는 아래와 같은 방식으로 저장할 수 있다.

delegate 반환형 델리게이트이름(매개변수들);

1.2) 델리게이트의 사용

간단하게 사용 예시를 들어보겠다.

델리게이트를 선언하는 방식은 아래와 같이 한다.

public delegate float DelegaeteMethod1(float left, float right);

float 변수 두 개를 받고 float을 반환하는 델리게이트를 만들었다.
이와 더불어 이 델리게이트 함수가 받을 함수를 만들어 보겠다.

public static float Plus(float left, float right) { return left + right }

간단하게 left와 right를 더하는 함수를 만들었다.

이제 이를 메인에서 사용해보자.

static void Main(string[] args)
{
	DelegaeteMethod1 delegaete = Plus;
    Console.WriteLine(delegate1(20f, 10.4f));
}

이를 출력하면 아래와 같이 나온다. 의도한 대로 출력된 것을 확인할 수 있다.

  • invoke

(누구의 이름을) 부르다, (프로그램 등을)불러오다 라는 뜻을 가지고 있으며, 델리케이트를 쓰는 상황에선 이 문법을 사용하는 상황이 좋을 때가 많다.

  • 델리게이트 사용 시 유의 사항

-델리게이트는 선언 시에 매개변수, 반환값을 명시하여야 한다. (반환값이 있으면 적고, 없으면 void도 가능)
-또한, delegate로 받을 함수 또한 delegate 함수와 일치하는 매개변수, 반환값을 가지고 있어야 한다.

1.3) 델리케이트를 사용해야 하는 이유?

델리케이트도 언뜻 보면은 신기한 기능이긴 하지만, 굳이 써야 할까 라는 생각이 들 수도 있다.
애초에 함수를 선언했으면 그 함수를 그대로 쓰면 되는지에 대한 의문이다.

아래에 델리케이트를 써야만 하는 상황에 대해 설명하고자 한다.

1.3.1) 콜백함수

아래와 같은 예시 상황을 생각해보자.

게임 내에서 사용할 버튼인 세이브 기능을 담은 버튼과 로드 기능을 담은 버튼을 구현한다는 상황을 가정해보자.

public class File
{
    public void Save()
    {
        Console.WriteLine("저장하기 합니다.");
    }
    public void Load()
    {
        Console.WriteLine("불러오기 합니다.");
    }
}

public class Button
{

}

static void Main(string[] args)
{
    File file = new File();

    Button saveButton = new Button();
    Button loadButton = new Button();
    
}

지금까지 배운 내용을 생각해보면, 세이브 버튼과 로드 버튼을 각각 구현하기 위해 Button 클래스를 상속하는 세이브버튼과 로드 버튼을 자식클래스를 만드는 방법을 생각할 수 있을 것이다.
물론 이런 아이디어로 구현은 가능하겠지만, 앞으로 게임을 만들면서 얼마나 많은 버튼을 만들게 되겠는가? 상속으로 이 문제를 해결하려 한다면 버튼 관련된 코드만 몇 백 줄은 나올 지도 모르는 일이다.

그러면 어떻게 해야 할까? 이를 위해서 델리케이트를 이용한 콜백 함수를 사용해 보자.

콜백함수

-델리게이트를 이용하여 특정 조건에서 반응하는 함수를 구현한다
-함수의 호출(Call)이 아닌 역으로 호출받을 때 반응을 참조하여 역호출(Callback)하는 것

이와 같은 방법을 이용하여 위의 코드를 완성시켜보자. 우선 Button 안에 델리케이트 함수를 선언한다.

public class Button
{
    public delegate void ClickListener();	// 델리케이트 함수를 선언(매개변수,반환x)
    public ClickListener clickListener;		// clickListener 함수 선언(내용x)
    public void Click()
    {
    	// 오류 방지용-clickListener가 있는지 확인하고 실행
        if(clickListener != null)			
        {
            clickListener();
        }
    }
}

델리케이트 함수를 만들고, 이 함수를 바탕으로 생성된 clickListener라는 함수에 각각, Save();, Load(); 함수를 담고, 버튼을 클릭한다.

static void Main(string[] args)
{
    File file = new File();

    Button saveButton = new Button();
    saveButton.clickListener = file.Save;	// saveButton 델리케이트 함수에 세이브기능

    Button loadButton = new Button();
    loadButton.clickListener = file.Load;	// loadButton 델리케이트 함수에 로드기능

    saveButton.Click();
    loadButton.Click();
}

이렇게 해서 버튼을 누르면 세이브와 로드 기능이 가능한 버튼을 구현할 수 있는데, 처음에는 어떻게 가능한 건지 한 번에 이해되지 않았다.

하지만 코드 전문을 놓고 다시 한 번 뜯어보자.

public class File
{
    public void Save()
    {
        Console.WriteLine("저장하기 합니다.");
    }
    public void Load()
    {
        Console.WriteLine("불러오기 합니다.");
    }
}

public class Button
{
    public delegate void ClickListener();
    public ClickListener clickListener;
    public void Click()
    {
        if(clickListener != null)
        {
            clickListener();
        }
    }
}

static void Main(string[] args)
{
    File file = new File();

    Button saveButton = new Button();
    saveButton.clickListener = file.Save;

    Button loadButton = new Button();
    loadButton.clickListener = file.Load;

    saveButton.Click();
    loadButton.Click();
}

로직을 이해하기 위해 역순으로 코드를 읽어보기로 했다.

세이브 버튼 기준으로 이 시스템이 어떻게 작동하는지 살펴 보자.

  1. saveButton.Click();이 실행되면 무슨 일이 생길까? 우선은 Button 클래스 안의 Click 함수가 실행될 것이다.
  2. clickListener가 null이 아니면 실행될 것이다. 우리는 앞의 코드에서 saveButton.clickListener 함수 안에 file.Save를 넣었기 때문에, 해당 함수는 null이 아니다. 따라서 실행될 것이다.
  3. 그러면 실행되는 함수가 뭐지? 다시 한 번 코드를 살펴 보면, saveButton.clickListener = file.Save; 이다. 즉 Click 버튼을 누른 것만으로 file.Save; 가 실행된다는 것이다.
  4. file.Save; 에는 무슨 실행이 들어가 있을까? "저장하기 합니다." 라는 문자열이 출력하게 만들어 놨다.

아하! 즉 클릭 버튼을 누르면 해당 버튼에 저장된 함수가 출력된다는 것이다. 그 함수를 저장하기 위해 델리케이트를 사용했고, 이로서 버튼 클래스를 무한정 늘리지 않고 쉽사리 저장 기능을 실행하고, 함수를 불러오는 방법을 알게 되었다.

1.3.2) 지정자

우선은 이 내용을 한 번은 다뤄 보고 싶어서 넣어놨으나, 현업에서 많이 쓰는 것은 아닌, 필수적인 기능은 아니라고 한다. 내용 자체도 많이 어려운 편이라서 참고용으로만 남겨놓는다.
(쓸 줄 알면 좋지만 굳이 쓰지 않아도 다르게 구현할 방법이 많다.)

지정자(Specifier)

델리게이트를 사용하여 미완성 상태의 함수를 구성
매개변수로 전달한 지정자를 기준으로 함수를 완성하여 동작시킴
기준을 정해주는 것으로 다양한 결과가 나올 수 있는 함수를 구성 가능

사용 예시 : 갯수를 세는 함수에서, 양수/음수/특정 조건의 수를 반환하는 부분을 델리케이트로 작성

void Main()
{
	int[] array = { 3, -2, 1, -4, 9, -8, 7, -6, 5 };

	int index1 = CountIf(array, IsPositive);            // 배열 중 값이 양수인 데이터 갯수
	int index2 = CountIf(array, IsNagative);            // 배열 중 값이 음수인 데이터 갯수
	int index3 = CountIf(array, value => value > 5);    // 배열 중 값이 5보다 큰 데이터 갯수
}

public static int CountIf(int[] array, Predicate<int> predicate)	// 델리케이트로 어떤 조건의 결과를 원하는지 표현
{
	int count = 0;

	for (int i = 0; i < array.Length; i++)
	{
		if (predicate(array[i]))
		{
			count++;
		}
	}

	return count;
}

public static bool IsPositive(int value)
{
	return value > 0;
}

public static bool IsNagative(int value)
{
	return value < 0;
}

1.4) 일반화 델리케이트

앞선 내용으로 델리케이트가 무엇인지, 어떻게 활용할 수 있는지 알아보았다.
그런데 앞에서 보았듯이 델리케이트를 선언하기 위해 public deligate void ~ 줄줄 쓰는 것이 상당히 귀찮다는 생각이 들었다.
그리고 이렇게 선언한 델리게이트의 경우 델리게이트끼리 이름도 일치하지 않으면 호환이 되지 않으니 상당히 불편하게 작용할 수도 있다. 이걸 위해서 일반화 델리케이트를 사용한다.

일반화 델리게이트

개발과정에서 많이 사용되는 델리게이트의 경우 일반화된 델리게이트를 사용

아주 간단하게 말해서 C#에서 기본적으로 제공해주는 델리케이트가 있다.

크게 두 종류, Func 델리케이트와 Action 델리케이트가 있는데, 알아보도록 하자.

1.4.1) Func 델리게이트

Func 델리케이트

반환형과 매개변수를 지정한 델리게이트


위와 같이 기본 제공되는 델리게이트이다.
반환형이 있을 때에는 Func 델리케이트를 사용하면 된다.

사용 형식
Func<매개변수1, 매개변수2, ..., 반환형>

1.4.2) Action 델리게이트

특히나 주목해서 봐야 할 일반화 델리케이트이다. (유니티에서 진짜 많이 쓴다 꼭 기억하자)

Action 델리케이트

반환형이 void 이며 매개변수를 지정한 델리게이트

반환형이 없을 때에는 Action 델리케이트를 사용하면 된다.

사용 형식
Action<매개변수1, 매개변수2, ...>

1.5) 델리케이트 체인

델리게이트 체인

델리게이트 변수에 여러 개의 함수를 참조하는 방법

델리케이트는 그 자체만으로 함수를 담는 것으로 끝나는 게 아니라, 함수 여러 개를 담는 것도 가능하자. 아래 예시를 보면서 어떻게 활용할 수 있는지 알아보자.

1.5.1) 델리케이트 추가저장

아래와 같이 델리케이트를 추가 저장할 수 있다.

static void Main(string[] args)
{
	Action action;
	action = Func1;     // 델리케이트에 Func1 저장
	action += Func2;    // 델리케이트에 Func2 추가저장
	action += Func3;    // 델리케이트에 Func3 추가저장
	action();           // Func1 Func2 Func3 순으로 호출됨
}

public static void Func1() { Console.WriteLine("Func1"); }
public static void Func2() { Console.WriteLine("Func2"); }
public static void Func3() { Console.WriteLine("Func3"); }

1.5.2) 델리게이트의 함수 삭제 및 대입의 상황

델리케이트에서 함수를 삭제할 수도 있으며 = 연산자를 쓰면 이전 기록을 무시하고 덮어 씌워버리니 유의하도록 하자.

...
	Action action;
	action = Func1;     // 델리케이트에 Func1 저장
	action += Func2;    // 델리케이트에 Func2 추가저장
	action += Func3;    // 델리케이트에 Func3 추가저장

	action -= Func1;
	action = Func2;     // 델리게이트에 = 을 통해서 대입하는 경우 이전의 참조된 상황이 사라짐
	action -= Func1;    // 델리게이트에 참조되지 않은 함수를 제거하는 경우 해당 작업이 무시됨(안터짐)
	action();
}
...

1.5.3) 델리게이트 오류

델리게이트의 값이 null일 때는 오류가 발생할 수 있다. 따라서 null인지 확인을 하고 함수를 불러오도록 한다.

...

	action = Func1;
	action -= Func1;
	action();           // 이렇게 하면 터질 수 있다. 그러므로 아래와 같이 if를 걸고 한다

	if(action != null)
	{
		action();
	}
}
...

1.6) 이벤트

아래와 같은 상황을 생각해보자.
플레이어와 몬스터의 상호작용을 정리하여 아래와 같이 코드를 작성했다고 하자.

public class Player
{
    public Action OnDied;	// OnDied 델리케이트
    public void Die()
    {
        Console.WriteLine("플레이어가 쓰러집니다.");
        if(OnDied != null)
        {
            OnDied();
        }
    }
}

public class Monster
{
    public void Stop()
    {
        Console.WriteLine("몬스터가 더이상 플레이어를 쫓아가지 않습니다.");
    }
}

public class Game
{
    public void GameOver()
    {
        Console.WriteLine("게임오버 UI를 띄웁니다.");
    }
}

public class SFX
{
    public void DieSound()
    {
        Console.WriteLine("슬픈 음악이 재생됩니다.");
    }
}

static void Main(string[] args)
{
    Player player = new Player();
    Monster monster1 = new Monster();
    Monster monster2 = new Monster();
    Game game = new Game();
    SFX sfx = new SFX();

    player.OnDied += monster1.Stop;
    player.OnDied += monster2.Stop;
    player.OnDied += game.GameOver;
    player.OnDied += sfx.DieSound;

    player.Die();
}

이와 같이 작성하고 출력을 확인해보면 의도대로 출력이 나온 것을 확인할 수 있다.

하지만 여기에서 코드 입력 중에 실수를 하여 다음과 같이 입력했다고 하자.

...

    player.OnDied += monster1.Stop;
    player.OnDied += monster2.Stop;
    player.OnDied();		// <실수로 작성한 코드
    player.OnDied += game.GameOver;
    player.OnDied += sfx.DieSound;

    player.Die();
}

이렇게 되면 출력이 어떻게 나올까?

의도치 않게 중간에서 OnDied 델리케이트를 호출하여 몬스터와 관련된 코드가 두 번 나온 것을 확인할 수 있다. 이와 같은 상황에서 우리가 예상할 수 있는 의도치 않은 상황으로는, 다음과 같은 상황을 생각할 수도 있다.

  • 플레이어가 죽지 않았는데 외부에서 실수로 죽여버리는 상황?
  • 다른 기능의 오작동 등

그러면 이러한 상황을 막기 위해서 어떻게 해야 할까? 이걸 위해 사용하는 것이 이벤트이다.

이벤트(Event)

-일련의 사건이 발생했다는 사실을 다른 객체에게 전달
-델리게이트의 일부 기능을 제한하여 이벤트의 용도로 사용

여기서 주목해야 할 점은 델리케이트의 일부 기능을 제한한다는 부분이다.

우선은 델리케이트에 이벤트를 선언해주자.


선언하는 방식은 위와 같이 event 를 붙여주면 된다. 이렇게 작성하고서 문제가 생겼던 코드를 다시 살펴보자.

실수를 가정하고 작성하였던 부분에 오류가 발생한 것을 확인할 수 있다.
적힌 내용을 확인해보면, OnDied(); 델리케이트를 외부에서 직접적으로 사용하는 것을 막고, "=" 연산자 사용을 제한했다.
이런 기능 제한으로 프로그래머의 실수를 방지하는 역할을 한다.

1.7) 델리케이트 체인과 이벤트

델리게이트 또한 체인을 통하여 이벤트로서 구현이 가능하다.
하지만 델리게이트는 두 가지 사항 때문에 이벤트로서 사용하기 적합하지 않다.

  1. = 대입연산을 통해 기존의 이벤트에 반응할 객체 상황이 초기화 될 수 있음
  2. 클래스 외부에서 이벤트를 발생시켜 원하지 않는 상황에서 이벤트 발생 가능

이와 같은 두 가지 상황을 방지하기 위해 event 키워드를 추가할 수 있다.
event 키워드는 델리게이트에서 위 두 가지 기능을 제한하여, 이벤트 전용으로 사용을 유도할 수 있다.

2. 익명함수와 람다식

우리는 매번 연산을 할 때 조건문이나 반복문을 사용하여 계산식을 만들거나, 함수로 만들어서 해당 계산을 진행하였다. 하지만, 일회성으로 쓸 함수를 만드는 것도 부담이며 그렇다고 메인 함수에 코드를 길게 쓰는 것도 가독성에 좋지 않을 것이다.
여기에서 익명함수와 람다식을 이용한 코드 작성법에 대해 알아보고자 한다.

2.1) 무명메서드(익명함수)

가령, 일회성으로 사용해야 할 함수가 있다고 하자. 간단하게 제곱수를 출력하는 함수를 만들어 보았다.

static void Main(string[] args)
{
    Console.WriteLine(Util.Pow(5, 3));                       
}

public class Util
{
    public static int Pow(int n, int x)
    {
        int result = 1;
        for (int i = 0; i < x; i++)
        {
            result *= n;
        }
        return result;
    }
}

해당 함수를 출력하면 125라는 결과로 잘 출력되기는 하지만, 한 번만 쓰일 함수로 메모리를 잡아먹는 건 그리 좋지 않아 보인다.
이와 같은 상황에서 우린 무명메서드(익명함수)를 사용한다.

무명메서드(익명함수)

-델리게이트 변수에 할당하기 위한 함수를 소스코드 구문에서 생성하여 전달한다
-전달하기 위한 함수가 간단하고 일회성으로 사용될 경우에 간단하게 작성하는 방법이다

델리게이트를 이용하여 연결된 함수를 직접 만들지 않고, 즉시 작성하는 방식이다.
그렇다면 무명메서드를 이용하여 위의 제곱을 구하는 함수를 다시 작성해보자.

Func<int, int, int> pow = delegate (int n, int x)
 {
     int result = 1;
     for (int i = 0; i < x; i++)
     {
         result *= n;
     }
     return result;
 };

이와 같은 방식으로 함수를 보다 간단하게 표현한 것을 확인할 수가 있다.

2.2) 람다식

람다식은 위에 설명한 무명메서드보다 더 간단하게 식을 표현할 수 있는 방식이다.
위의 제곱수 함수를 람다식으로 보다 간단하게 표현해보자.

pow = (n, x) =>
{
    int result = 1;
    for (int i = 0; i < x; i++)
    {
        result *= n;
    }
    return result;
};

2.3) 람다식 사용 사례

람다식이 함수를 간단히 표현하는 데에 도움이 된다는 사실을 알았다. 하지만 이걸 굳이 사용해야 하나 싶은 생각이 들 수도 있다.

예시 사례로 람다식을 사용해야 하는 이유를 알아보자.

아래와 같이 플레이어가 피격되었을 때 피격음 소리가 나는 구조를 만들고자 한다.

public class Player
{
    private int hp = 100;

    public Action<int> OnTakeDamaged;

    public void TakeDamage(int damage)
    {
        hp -= damage;
        Console.WriteLine("플레이어가 {0} 데미지를 받습니다.");

        if (OnTakeDamaged != null)
        {
            OnTakeDamaged(damage);
        }
    }
}

public class SFX
{
    public void HitSound()
    {
        Console.WriteLine("피격음 사운드를 재생합니다.");
    }
}

하지만 위에서 본 코드의 문제점으로는, TakeDamage 함수와 HitSound 함수의 매개변수가 다르다는 것이다. 이로 인해 델리케이트로 두 함수를 연결시키는 건 어려워 보인다.
그렇다고 HitSound에 불필요하게 int 매개변수를 넣어주기도 그렇다. 이럴 때 람다 함수를 쓴다.

메인에 이렇게 선언하면 사용할 수 있다.

Player player = new Player();
SFX sfx = new SFX();

player.OnTakeDamaged += (damage) => { sfx.HitSound(); };
player.TakeDamage(2);

플레이어가 데미지를 입었을 때 HitSound가 실행되도록 작성한 코드이다. 이렇게 작성하고 출력해보자.



참고자료
(Delegate, Invoke)
https://blog.naver.com/aorigin/100140073826
(Unity/C# Invoke에 관하여)
https://wonsang98.tistory.com/entry/Unity-C-%EC%9D%B8%EB%B3%B4%ED%81%ACInvoke

profile
게임 만들러 코딩 공부중

0개의 댓글