함수를 변수에 담을 수 있다면, 함수가 함수를 돌려줄 수도 있습니다. 계산 방법을 통째로 바꿔 끼우고 싶을 때 쓰는 방법입니다. 이번 편에서는 Func 로 계산 함수를 주고받고, async 람다를 맛보고, 람다가 바깥 값을 붙잡는 클로저를 키 입력으로 확인합니다.
2024년 부산 콘텐츠 아카데미 퍼스널 트레이닝 수업에서 MyLesson Unity 실습 프로젝트의 Strategy_14_Func.cs 로 가르친 내용입니다. 수업은 세 단계로 나뉘고, 단계마다 코드가 조금씩 바뀝니다.
계산 방법을 돌려주는 인터페이스
public interface FuncProvider
{
public Func<int, int, int> ProvideCrossMethod();
public Func<int, int, int> ProvideAddMethod();
}
public class FuncProvider1 : FuncProvider
{
public Func<int, int, int> ProvideCrossMethod()
{
Func<int, int, int> crossCalc = (a, b) =>
{
Program.WriteLine("{0} * {1} =", a, b);
return a * b;
};
return crossCalc;
}
public Func<int, int, int> ProvideAddMethod()
{
Func<int, int, int> addCalc = (a, b) => a + b;
return addCalc;
}
}Func<int, int, int> 는 "int 둘을 받아 int 하나를 돌려주는 함수" 입니다. 마지막 타입 매개변수가 반환 타입입니다. FuncProvider 인터페이스는 그런 함수를 돌려주는 메서드 둘을 약속합니다.
람다는 두 모양으로 썼습니다. 여러 줄이 필요한 곱셈은 중괄호와 return 이 있는 문 람다, 한 줄짜리 덧셈은 식 람다 (a, b) => a + b 입니다.
public void Run()
{
FuncProvider ofp = new FuncProvider1();
crossCalc = ofp.ProvideCrossMethod();
addCalc = ofp.ProvideAddMethod();
Program.WriteLine("FuncProvider1");
Program.WriteLine(crossCalc(2, 5));
Program.WriteLine();
Program.WriteLine("(outer) 5 + 5 =");
Program.WriteLine(addCalc(5, 5));
Program.WriteLine();
ofp = new FuncProvider2();
crossCalc = ofp.ProvideCrossMethod();
addCalc = ofp.ProvideAddMethod();
Program.WriteLine("FuncProvider2");
Program.WriteLine(crossCalc(2, 5));
Program.WriteLine();
Program.WriteLine("(outer) (5 + 5)^2 =");
Program.WriteLine(addCalc(5, 5));
}FuncProvider1
2 * 5 =
10
(outer) 5 + 5 =
10
FuncProvider2
(2 * 5)^2 =
100
(outer) (5 + 5)^2 =
100Run 은 crossCalc(2, 5) 라고만 부릅니다. 어떤 계산이 일어날지는 어느 FuncProvider 가 함수를 줬느냐에 달렸습니다. 1편의 Strategy 처럼, 이번에는 클래스가 아니라 함수를 갈아 끼웠습니다.
async 람다와 Task
public class AsyncFuncProvider
{
public Func<int, int, Task<int>> ProvideCrossTask()
{
Func<int, int, Task<int>> crossCalc = async (a, b) =>
{
//await Task.Delay(1000);
Program.WriteLine("{0} * {1} =", a, b);
return a * b;
};
return crossCalc;
}
}public void Run()
{
var afp = new AsyncFuncProvider();
someTask = afp.ProvideCrossTask();
Program.WriteLine("AsyncFuncProvider");
Program.WriteLine(someTask(2, 5).Result);
}람다 앞에 async 를 붙이면 결과를 바로 주지 않고 Task<int>, 곧 "나중에 int 가 나올 약속" 을 돌려줍니다. .Result 는 그 약속이 끝날 때까지 기다렸다가 값을 꺼냅니다.
람다 안의 await Task.Delay(1000) 이 주석이라, 지금은 await 가 하나도 없어 그냥 동기로 돌고 컴파일러가 경고(CS1998)를 냅니다. 주석을 풀고 .Result 로 기다리면 Unity 의 메인 스레드가 1초 멈춥니다. 게임에서 기다릴 때는 다음 편 이후에 볼 코루틴이나 await 를 끝까지 이어 쓰는 방법을 씁니다.
클로저, 람다가 바깥 값을 붙잡는다
private float accTime = 0;
private int varA = 0;
private int varB = 0;
private Action addCalcNoParam = null;
public void Run()
{
FuncProvider ofp = new FuncProvider1();
crossCalc = ofp.ProvideCrossMethod();
addCalcNoParam = delegate
{
Program.WriteLine("{0} + {1} =\r\n{2}", varA, varB, (varA + varB));
};
}
public void Update()
{
accTime += Time.deltaTime;
if (accTime > 5.0f)
{
Program.WriteLine(crossCalc(varA, varB));
addCalcNoParam();
accTime = 0;
}addCalcNoParam 는 매개변수가 없는 Action 입니다. 그런데 안에서 varA, varB 를 씁니다. 람다나 무명 메서드가 바깥의 변수를 쓰면, 그 변수를 값으로 복사해 두지 않고 변수 자체를 붙잡아 둡니다. 이것을 클로저라고 합니다.
bool ctrl = Input.GetKey(KeyCode.LeftControl);
bool alt = Input.GetKey(KeyCode.LeftAlt);
if (ctrl || alt)
{
int temp = 0;
if (Input.GetKeyUp(KeyCode.Keypad1))
{
temp = 1;
}
else if (Input.GetKeyUp(KeyCode.Keypad2))
{
temp = 2;
}
else if (Input.GetKeyUp(KeyCode.Keypad3))
{
temp = 3;
}
else if (Input.GetKeyUp(KeyCode.Keypad4))
{
temp = 4;
}
else if (Input.GetKeyUp(KeyCode.Keypad5))
{
temp = 5;
}
if (temp != 0)
{
if (ctrl)
{
varA = temp;
Program.WriteLine("varA에 {0}이 입력되었습니다.", temp);
}
else
{
varB = temp;
Program.WriteLine("varB에 {0}이 입력되었습니다.", temp);
}
}실행 중에 왼쪽 Ctrl + 키패드 숫자로 varA 를, 왼쪽 Alt + 키패드 숫자로 varB 를 바꿉니다. 5초마다 불리는 addCalcNoParam 은 Run 에서 만들어진 함수인데도 부를 때의 최신 값으로 계산합니다.
varA에 3이 입력되었습니다.
varB에 4이 입력되었습니다.
3 * 4 =
12
3 + 4 =
7이 예에서 varA · varB 는 필드라 람다는 객체(this)를 붙잡습니다. 지역 변수를 쓰면 그 지역 변수를 붙잡습니다. 반복문 안에서 람다를 여러 개 만들 때 모두 같은 변수를 붙잡아 같은 값을 보는 실수가 자주 납니다.
돌아보며
이번 편에서 얻은 것
- Func 의 마지막 타입 매개변수는 반환 타입이다
- 인터페이스가 함수를 돌려주게 하면 계산 방법을 갈아 끼울 수 있다
- 람다는 바깥 변수를 붙잡아 두고, 부를 때의 값을 쓴다
아쉬운 점
- async 람다에 await 가 없어 비동기의 효과를 볼 수 없다
- .Result 로 기다리는 방식은 실제 게임 코드에서는 피해야 한다
정리
- Func 는 값을 돌려주는 델리게이트입니다. Func<int, int, int> 는 int 둘을 받아 int 를 돌려줍니다.
- 함수도 반환값이 될 수 있습니다. 계산 방법을 돌려주는 FuncProvider 를 바꾸면 계산 전체가 바뀝니다.
- 클로저는 값이 아니라 변수를 붙잡습니다. 그래서 나중에 불러도 그때의 최신 값을 봅니다.
다음 편에서는 함수가 값을 하나씩 나눠 돌려주는 yield return 과 IEnumerable 을 봅니다.