언어/C#

struct 와 in, readonly

tsyang 2024. 3. 20. 00:59

struct의 복사 비용


struct는 단독으로 사용하면 힙에 생성되지 않는다는 장점이 있다. 그대신 다른 메서드의 매개변수 등으로 주어질 때 항상 값 복사가 일어난다. 당연히 struct 내부에 필드가 많으면 많을수록 복사 비용이 커지게 된다.

 

public struct SomeStruct
{
    public int A, B, C;
}

public static bool IsZero(SomeStruct s)
{
    return (s.A | s.B | s.C) == 0;
}

 

가령 IsZero메서드를 호출하면 SomeStruct의 복사가 일어난다. 

 

 

이를 방지하고 싶으면 ref키워드를 쓸 수 있다.ref키워드로 값 타입을 넘길 땐, 해당 타입 전체를 스택에 복사하는 대신 해당 타입의 주소만을 복사한다.

public static bool IsZeroRef(ref SomeStruct s)
{
    return (s.A | s.B | s.C) == 0;
}

 

 

in키워드와 struct


그렇다면 값 타입을 주고 받을 땐 ref키워드를 무조건 쓰는 게 좋아보인다. 그러나, ref키워드를 사용한다면 매개변수로 넘겨받은 값 타입의 원본이 수정될 여지가 있다.

 

이를 막고자, C#에는 in이라는 키워드가 존재한다. in은 readonly ref와 동일한데, 매개변수로 넘겨진 값 타입을 수정할 수 없게 한다.

 

public struct SomeStruct
{
    public int A, B, C;
}

public static void IncreaseA(in SomeStruct s)
{
    s.A += 1;   //compile Error
}

 

 

그렇다면 매개변수로 넘겨받은 s가 자신의 상태를 변경시키는 메서드를 호출하는 것은 어떨까?

 

public struct SomeStruct
{
    public int A, B, C;

    public void IncA() { ++A; }
}

public static void IncreaseA(in SomeStruct s)
{
    s.IncA();	//OK
}

 

컴파일은 잘 된다. 

 

그러나 그 값을 출력해보면 이상한 문제가 보인다.

var s = new SomeStruct { A = 0, B = 0, C = 0 };
Console.WriteLine(s.A);
IncreaseA(s);
Console.WriteLine(s.A);

//출력
//---------
//0
//0

 

컴파일 에러는 발생하지 않았고, ref로 넘겨진 s의 내부 값을 증가시키는 메서드도 호출했는데, 값은 그대로이다!

 

이렇게 되는 이유를 알기 위해서는 아래의 방어 복사본 개념을 알아야 한다.

 

 


 

 

Struct의 방어 복사본(Defensive Copy)


public struct SomeStruct
{
    public int A, B, C;

    public void IncA() { ++A; }
}

public class SomeClass
{
    public readonly SomeStruct S = new SomeStruct() { A = 0, B = 0, C = 0 };

    public void Foo()
    {
        // S.A += 1;   //Compile Error
        Console.WriteLine(S.A);
        S.IncA();
        Console.WriteLine(S.A);
        
        //0 0을 출력함
    }
}

 

S는 readonly이기 때문에 초기화 이후의 그 값의 수정은 불가능하다.

 

그럼에도 S의 InA메서드를 호출할 수 있다. 알아야 할 것은 위 코드도 0,0을 출력한다는 점이다. 

 

컴파일 에러도 안 발생하는데, 값도 수정이 안 되는 이상한 상황이 발생한다. 왜일까?

 

그것은 C#컴파일러가 readonly키워드가 붙은 구조체의 메서드를 호출할 때는 항상! 복사본을 생성하기 때문이다. 

 

 

즉, 아래의 두 코드는 똑같다.

S.IncA();
var temp = S;
temp.IncA();

 

 

 

실제로 IL코드를 생성해보면.. 정확히 똑같음을 알 수 있다.

//IncA();
IL_0001: ldarg.0      // this
IL_0002: ldfld        valuetype SomeStruct SomeClass::S
IL_0007: stloc.0      // V_0
IL_0008: ldloca.s     V_0
IL_000a: call         instance void SomeStruct::IncA()
IL_000f: nop

//-------------------------------------------------------

//temp = S; temp.IncA();
IL_0001: ldarg.0      // this
IL_0002: ldfld        valuetype SomeStruct SomeClass::S
IL_0007: stloc.0      // temp
IL_0008: ldloca.s     temp
IL_000a: call         instance void SomeStruct::IncA()
IL_000f: nop

 

 

 

이는 컴파일러가 readonly로 설정된 인스턴스의 값을 변경하지 않도록 하기 위한 처리이다. 

 

심지어 S의 멤버 변수를 변경하지 않는 메서드에 대해서도 방어 복사본을 생성한다.

S.DoNothing();  //이 경우에도 방어 복사본을 생성한다.

 

 


 

in은 struct랑 쓰지마!


다시 in 키워드에 대한 얘기로 돌아와보자. in은 곧 readonly ref라 하였다. 즉 매개변수로 in과 struct를 사용한다는 말은, 해당 매개변수가 readonly인 struct가 된다는 뜻이고, 이는 곧 매개변수의 메서드를 호출할 때 마다 방어 복사본을 생성한다는 말이다.

public struct SomeValue
{
    public int Index;
    public int A;

    public long L1, L2, L3, L4, L5, L6, L7; //복사비용이 크다..!
    
    public bool IsValid() { return Index > 0; }
}

public int GetA(in SomeValue someValue)
{
    if (someValue.IsValid())	//매번 방어 복사본을 만든다.
        return -1;
    
    return someValue.A;
}

 

그저 참조를 받아 성능을 향상시키며, readonly로 원본값을 보호해주고 싶었을 뿐인데... 엄청난 성능 저하가 생겨 버렸다.

 

무서운 점은 위 코드가 엄청난 성능 저하를 발생시킬 수 있다는 사실이 티가 안 난다는 것이다.

 

 


 

그래도 쓰고싶어!! (C# 7.2이상)


위와 같은 답 없는 문제를 방지하고자, C#에서는 readonly struct라는 것을 만들었다.

 

사용법은 간단하다. 그냥 struct 선언 전에 readonly 키워드를 붙이고, 모든 필드에도 readonly를 붙여주면 된다. (안 붙이면 컴파일 에러)

public readonly struct SomeValue
{
    public readonly int Index;
    public readonly int A;

    public readonly long L1, L2, L3, L4, L5, L6, L7; //복사비용이 크다..!
    
    public bool IsValid() { return Index > 0; }
}

 

이 상태에서 동일한 메서드를 호출하면? 

public int GetA(in SomeValue someValue)
{
    if (someValue.IsValid())
        return -1;
    
    return someValue.A;
}

 

어떤 방어 복사도 안 생성한다. (IL코드 보면 알 수 있음)

 

왜냐? 어쩌피 readonly 구조체는 모든 필드가 readonly이기 때문에 컴파일러가 방어 복사본을 만들어서 원본을 지켜줄 필요가 없기 때문이다.

 


 

 

수정도 하고싶어!! (C# 8.0이상)


 

근데 상식적으로 struct자체가 readonly여야 한다는 제약은 너무 크지 않나? 만약에 수정이 가능한 struct가 있다면 어떡하나?

 

그래서 C#에서는 Readonly-Member라는 게 추가됐다.

 

public struct SomeValue
{
    public readonly int Index;
    public int A;

    public long L1, L2, L3, L4, L5, L6, L7; //복사비용이 크다..!
    
    //Readonly로 선언된 "메서드"
    public readonly bool IsValid() { return Index > 0; }
}

 

메서드나 프로퍼티 앞에 readonly를 붙여주면, 해당 메서드 내부에서는 더 이상 멤버 변수의 수정이 불가능해진다. (참조 타입에서는 붙이면 컴파일 에러)

 

그럼 이제 해당 메서드를 통해서 원본이 변경될 여지가 없다는 뜻이니 컴파일러가 방어 복사본을 만들어 원본을 지켜줄 필요가 없게 된다!

 

실제로 아래 메서드의 IL코드를 보자. 복사본이 안 생겼다.

public bool IsValid(in SomeValue someValue)
{
    return someValue.IsValid();
}

 

//#readonly를 안 붙인 IsValid()를 호출했을 때.#

.locals init (
  [0] valuetype SomeValue V_0,	//스택에 복사본이 할당되어있다.
  [1] bool V_1
)
//...
IL_0001: ldarg.1      // someValue
IL_0002: ldobj        SomeValue
IL_0007: stloc.0      // V_0
IL_0008: ldloca.s     V_0
IL_000a: call         instance bool SomeValue::IsValid()
IL_000f: stloc.1      // V_1
IL_0010: br.s         IL_0012


//------------------------------

//readonly를 붙였을 때.

.locals init (
  [0] bool V_0	//스택에 복사본이 없다.
)
//...
IL_0001: ldarg.1      // someValue
IL_0002: call         instance bool SomeValue::IsValid()
IL_0007: stloc.0      // V_0
IL_0008: br.s         IL_000a

 

 

 

 

 

 

 

요약


in이랑 struct를 쓰고싶으면 메서드에 readonly를 잘 붙여라. (C#8.0 이상)

in이랑 struct를 쓰고 싶으면 readonly struct를 써라 (C#7.2 이상)

in이랑 struct는 같이 쓰면 성능 저하가 있다는 걸 알아라 (C#7.2 미만)

 

 

'언어 > C#' 카테고리의 다른 글

Task.Delay (vs Thread.Sleep)  (2) 2024.04.10
동시성 개요  (0) 2024.04.01
가비지 없이 foreach 사용하기  (0) 2024.01.21
Dynamic 타입  (0) 2024.01.14
CLR? .NET? Mono?  (0) 2023.10.01