코루틴과 yield의 마법
유니티를 접하면서 코루틴을 처음 써보면 대기하는 것 같아 신기하다. 분명히 코드는 한줄한줄 순서대로 실행되는데 어떻게 yield에서 대기해놓고 다른곳을 실행할 수 있을까?
IEnumerator와 yield가 정확히 어떻게 동작하는지는 찾아보면, 대략 MoveNext()를 호출할 때마다 다음 yield를 만날 때까지 실행해서 값을 반환한다는 식으로 이해된다. 약간 더 깊이 파고들면, 프로그래밍 언어에서 함수 호출을 하는 방식을 생각해볼 수 있다. 함수를 호출하면 스택에 반환될 주소와 함수 내부의 지역변수들이 추가된다. 그런데 만약 함수에서 값을 반환해버리면 스택이 없어질테고, 다음에 "중간부터 다시 시작" 할 수는 없을 것 같다.
어떻게 이런 일이 가능한 것일까?
IEnumerable/IEnumerator를 구현하는 클래스
먼저 IEnumerable/IEnumerator를 구현하는 클래스부터 살펴보자.(IEnumerable은 IEnumerator를 가지는 클래스이므로 열거한다는 개념적으로는 그게 그거라고 생각할 수 있다.) C#에서는 enumerate, 즉 열거한다고 표현하지만 python이나 javascript등 yield를 다른 언어들에서는 흔히 이런 함수를 generator라고 부른다. "값을 생성"하는 무언가라는 점에서 열거한다는 표현보다 다른 언어의 generator, 생성기라는 표현이 더 직관적으로 알기 쉽다.
public class Generator100 : IEnumerable<int>
{
public class MyEnumerator : IEnumerator<int>
{
private int _current;
int IEnumerator<int>.Current => _current;
object IEnumerator.Current => _current;
void IDisposable.Dispose()
{
}
bool IEnumerator.MoveNext()
{
if (_current < 100)
{
_current++;
return true;
}
return false;
}
void IEnumerator.Reset() // 원래는 구현이 있어야 하지만, 구현하지 않음
{
}
}
IEnumerator<int> IEnumerable<int>.GetEnumerator() => new MyEnumerator();
IEnumerator IEnumerable.GetEnumerator() => new MyEnumerator();
}
foreach (var a in new Generator100()) // 1부터 100까지 출력됨
{
Console.WriteLine(a);
}
위의 클래스는 실제로 작동하는 1부터 100까지의 수를 생성하는 생성기 클래스이다. 아랫쪽의 foreach문의 결과로 볼 수 있는 것처럼 위의 클래스는 MoveNext에서 false를 반환할 때까지 1부터 100까지의 수를 순차적으로 반환하게 된다.
코드는 비교적 간단하다. 단순히 클래스의 멤버변수 _current가 있고, MoveNext를 호출할 때마다_current를 증가시킨다. 그리고 _current가 100이 되면 MoveNext에서 false를 반환한다.
호출하는쪽에서는 MoveNext에서 false가 반환될 때 까지 MoveNext를 계속 호출한다.
IEnumerator를 생각하지 않고, 단순히 숫자 100개를 생성하는 클래스라고 생각하면 이해가 쉬울 것이다.
.
IEnumerable을 반환하는 메서드
그런데 1부터 100까지의 수를 생성하려면 굳이 클래스를 만들지 않아도 된다.
IEnumerable<int> Enumerate100()
{
int a = 0;
for (int i = 0; i < 100; i++)
{
a += i;
yield return a;
}
}
foreach (var a in Enumerate100())
{
Console.WriteLine(a);
}
위와 같은 코드도 클래스 버전과 동일하게 작동한다.
foreach문의 실행 순서를 잘 따라가보면, Enumerate100 메서드에서 yield를 만날때마다 메서드에서 빠져나와서 콘솔에 출력을 하고, 다시 들어가서 이어서 실행하는 것 처럼 보인다. 어떻게 가능한 것일까?
해답은 간단하다. 클래스로 구현했을 때와 동일한 일이 벌어진다..
일단 C# 컴파일러는 익명 클래스를 하나 만든다. 그리고 그 클래스 안에 메서드 안에서 사용하는 지역변수들을 필드로 선언한다. 그리고는 yield문을 만나면 yield문 앞뒤로 메서드를 조각조각낸다. 그리고 yield와 yield 사이를 state로 간주한다. 그리고 그 state들간을 이동시키는 상태 기계를 만든다. 흔히 FSM이라고 하는 그것이다. 그리고 MoveNext가 호출될때마다현재 상태에 해당하는 부분만 실행시켜 답을 돌려준다.
그림으로 표현하면 대략 위와 같다.
위의 Enumerate100() 메서드가 실제로는 대충 아래와 같은 느낌의 클래스로 변환되는 것이다. (실제로 정확히 이런게 나온다는 뜻은 아니다. 물론 C#코드 레벨에서 일어나는 일도 아니다. 이 작업은 IL에서 이루어진다.)
public class ConvertIntoThis
{
private int _state;
private int i;
private int a;
public void Init()
{
_state = 0;
i = 0;
a = 0;
}
public bool Run(ref int result)
{
if (_state == 0)
{
a += i;
result = a; // yield에 해당
i++;
if (i < 100)
_state = 0;
else
_state = 1;
return true;
}
else
{
return false;
}
}
}
위의 클래스로부터 값을 뽑아내려면 대략 아래와 같은 느낌의 작업을 하게 된다.
var obj = new ConvertIntoThis();
obj.Init();
int result = 0;
while (obj.Run(ref result))
{
Console.WriteLine(result);
}
실제로 위 코드의 출력은 Enumerate100() 메서드를 foreach로 순회했을 때와 완벽하게 같게 나온다.
마무리..
IEnumerable/IEnumerator를 사용할 때 실제로 어떤 일이 벌어지는지 알아보았다.
어떤 값을 생성하는 생성기 클래스를 정의하는 것과 똑같은 일이 벌어지며, 이것이 바로 IEnumerable이 동작하는 원리이다.
맨 마지막 코드블럭에서 볼 수 있듯이, IEnumerable을 사용하면 실제로 어떤 클래스의 인스턴스를 생성하게 된다. 당연하게도 이 작업은 힙에의 메모리 할당 및 가비지가 발생하고, IEnumerable이 단순 루프보다 성능이 절대로 좋을 수 없는 한 가지 이유가 된다.
'C#' 카테고리의 다른 글
LINQ(2) - 일반적인 LINQ 코드 작성 원칙 및 유의점 (0) | 2024.03.21 |
---|---|
LINQ(1) - 함수형 프로그래밍에 대해서 (0) | 2024.03.09 |
C#의 프로퍼티와 필드는 뭐가 다를까? (0) | 2024.03.07 |
C#의 확장 메서드는 언제 사용하면 좋을까? (0) | 2024.02.24 |
C#의 readonly 키워드와 불변성에 대해 (0) | 2023.09.07 |