Cave
#dev-etc

Advanced C# - 1

struct와 class, value/reference에 대하여

최근 깃허브 자기소개에 내 기술스택을 주기적으로 정리하는 과정에서 내가 정말 자신있는 기술에 대해서 보다보니 C#이 눈에 많이 띄었습니다. 생각해보면 C#을 굉장히 오랜기간동안 많이 써왔고 Winforms부터 WPF 그리고 지금은 Unity를 사용하고 있는데 이러한 작업을 위해 C#을 썼다는 느낌이지 정말 순수하게 C#에 대한 지식이 있다고 볼수있을까요?

이런 생각이 들다보니 제가 C#에 대해서 얼마나 알고있는지에 대해서 좀 깊이감있게 다시 확인할겸 다른 사람들도 많이 도움이 되었으면하는 마음에 이곳에 내용을 정리해두겠습니다.

해당 본문은 프로그래밍에 대한 어느정도의 기본 지식이 있다는것을 전제로 작성되어있습니다.


우선 완전 기초중의 기초부터 시작해볼까요? 프로그래밍을 처음 접했을때를 생각해보면 우리는 값(value)타입과 참조(reference)타입에 대해서 배웁니다. 이 부분에 대해서 조금 더 여러가지 관점으로 다뤄보겠습니다.

Value & Reference

값타입은 우선 기본적으로 복사형식으로 사용된다는걸 알고있습니다. 메소드의 파라미터로 value타입이 전달된다면 당연히 복사형식으로 넘어가니 struct가 커지면 커질수록 메소드 호출시에 복사비용이 커지고 호출시의 오버헤드가 생깁니다. 이러한 복사형식으로 사용된다는건 함수내에서의 struct의 변경은 원본 데이터에는 영향을 주지 못하며 이는 immutable한 데이터라면 struct가 더 유용하다고 알 수 있습니다. 이는 설계의 관점이지 struct가 꼭 immutable 해야한다는 이유는 아닙니다.

자 여기까지는 원론적인 이야기입니다. 예시로 유니티에 있는 Vector를 볼까요? 왜 Vector는 struct로 구현되어있을까요? 어떻게 보면 Vector는 immutable하다고 느끼지 못할 수 있습니다. 게임내에서 항상 변화하는 값이기때문에 reference로 하는게 더 적합하지 않나 라는 의문이 드는건 자연스러운 현상입니다.

우선적으로 Vector내에 존재하는 몇몇개의 함수에 대해서 생각해봅시다. Normalize, Dot, Project... 하나의 벡터에서도 수많은 파생의 벡터가 필요한 경우는 우리는 수없이 경험해봤을겁니다. reference타입이라면 이러한 메소드들은 전부 매개변수로 들어온 Vector들에 변형이 가해질수도 있습니다. 물론 원본 데이터에 영향을 가하는경우는 없습니다. 하지만 리턴타입이 Vector로 나오는 경우를 생각해봅시다. 두개의 normalized 벡터를 더하는 연산을 한다고 가정할때 총 3개의 임시 벡터가 생깁니다. 게임 내에서는 무수히 많은 벡터들이 연산되는데 이 모든 벡터연산들이 만약 class로 구성되어있고, 이 모든 벡터들이 힙으로 올라간다면 굉장히 잦은 GC가 호출된다는 문제점이 있습니다. 이것이 Vector가 struct로 구현되어있는 원인입니다.

Boxing & Unboxing

위와 관련된 내용입니다. 익숙하시겠지만 박싱과 언박싱도 마찬가지로 스택에서 힙으로, 힙에서 스택으로 오가는 과정에서의 오버헤드가 발생합니다. 반대로 말하면 object로 받고 형변환을 할때 똑같은 힙 영역이라면 그렇게 큰 오버헤드는 발생하지 않는다는것도 의미합니다.

사용 예제로 제가 구현한 Capricorn이라는 Dialogue System의 코드를 봅시다.

CapricornRunner.cs#L139

private IEnumerator ExecuteCoroutine(CoroutineUnit unit)
{
    switch (unit)
    {
        case WaitUnit waitUnit:
            yield return waitUnit.Execute();
            break;
        case ShowCharacterUnit showCharacterUnit:
            yield return showCharacterUnit.Execute(settings.characterArea, characters);
            break;
        case ChangeBackgroundUnit changeBackgroundUnit:
            yield return changeBackgroundUnit.Execute(settings.backgroundArea, cache.lastBackground);
            break;
        case ChangeForegroundUnit changeForegroundUnit:
            yield return changeForegroundUnit.Execute(settings.foregroundArea, cache.lastForeground);
            break;
            [...]
public abstract IEnumerator Execute(params object[] args);

위 코드에서 CoroutineUnit은 Execute(params object[] args)라는 가변적인 변수를 받도록 설계했습니다. Dialogue System에서 연출쪽에 해당하는 Coroutine영역을 확장하면서 어떤 데이터라도 사용할 수 있도록 하기위해 해당방법을 채용한것인데 어떻게 변경해야할지 요즘도 계속 고민중이긴합니다.

여기서 params에 들어가는 데이터들을 보면 Transform, GameObject, Dictionary 데이터들입니다. 즉 위 코드에서는 boxing/unboxing이 발생하지 않습니다.

주의해야하는 한가지 struct와 연계되어있는 interface에 대해서 살펴봅시다. struct는 value타입이고, interface는 reference타입 입니다. 만약 struct에 있는 interface를 사용하려 한다면 어떻게 될까요? 유니티에 Vector3를 예시로 들어봅시다.

public struct Vector3 : IEquatable<Vector3>, IFormattable
{

}

// Somewhere
public void Test(IFormattable @param)
{
    // Do something    
}

---

Test(Vector3.zero);

위 케이스에서는 어떤일이 생길까요? 정답은 Vector3(struct)에서 IFormattable(interface)로 변환되는것이기 때문에 박싱이 생깁니다.

하지만 아래와 같은 방법으로 우회하는것도 가능합니다.

public void Test<T>(T @param) where T : struct, IFormattable
{
    // no boxing
}

위 코드에서는 제네릭으로 타입을 명시할경우에 컴파일 타임에 직접 호출로 IL이 생성되기때문에 박싱이 발생하지 않습니다.


주제와 관련된 내용으로 더 깊게 다룰내용을 말씀해주시면 추가하도록 하겠습니다.

Comments