C# 13 - (7) ref struct의 interface 상속 및 제네릭 제약으로 사용 가능
C# 7.2에 추가됐었던,
C# 7.2 - 스택에만 생성할 수 있는 값 타입 지원 - "ref struct"
; https://www.sysnet.pe.kr/2/0/11530
ref struct에 interface를 상속할 수 있게 됐습니다.
[Proposal]: Let ref structs implement interfaces and substitute into type parameters (VS 17.11, .NET 9)
; https://github.com/dotnet/csharplang/issues/7608
따라서 C# 13부터 다음과 같은 코드가 가능합니다.
internal class Program
{
static void Main(string[] args)
{
MyStruct myStruct = new MyStruct(42);
myStruct.Log(Console.Out);
}
}
interface ILog
{
void Log(TextWriter tw);
}
ref struct MyStruct : ILog
{
int _x;
public MyStruct(int x) => _x = x;
public void Log(TextWriter tw)
{
tw.WriteLine($"x == {_x}");
}
}
하지만, 그렇다고 해서 ref struct 본연의 특징인 "스택에만 생성할 수 있다"라는 제약을 벗어날 수는 없습니다. 이로 인해 인스턴스를 인터페이스로 형변환하는 것은 여전히 불가능합니다.
MyStruct myStruct = new MyStruct(42);
IMy my = myStruct as IMy; // interface로의 변환은 Boxing을 유발하므로 불가능 (컴파일 오류)
Console.WriteLine(my.X);
error CS0039: Cannot convert type 'MyStruct' to 'ILog' via a reference conversion, boxing conversion, unboxing conversion, wrapping conversion, or null type conversion
이런 특징은 자연스럽게 (C# 8.0에 추가된) 메서드 구현을 포함하는 인터페이스까지 영향을 미칩니다.
interface IA
{
void M() { WriteLine("IA.M"); }
}
class C : IA { } // OK
IA i = new C();
i.M(); // 인터페이스에 구현한 기본 메서드는 반드시 형변환을 해야 사용 가능
따라서 만약 구현 타입이 ref struct 유형이라면 인터페이스로의 형변환을 할 수 없으니 기본 메서드를 호출할 수 있는 방법이 없습니다.
결국, ref struct의 경우에는 반드시 해당 메서드를 재정의하도록 제한을 두었습니다.
interface IA
{
void M() { WriteLine("IA.M"); } // 일반적으로 구현 클래스 측에서 M 메서드를 재정의하지 않아도 되지만,
}
ref struct C : IA
{
public void M() { WriteLine("C.M"); } // ref struct 타입은 인터페이스의 기본 메서드를 반드시 재정의해야 함
}
C i = new C();
i.M(); // 인터페이스로의 형변환 없이, 재정의한 메서드 M을 호출
그런데 좀 이상하지 않나요? ^^ 인터페이스를 상속해도 (다형성에 기반한 원칙으로) 쓸 수 없다면 왜 굳이 신규 문법으로 제공하는 걸까요?
왜냐하면, 그래도 딱 하나 유용한 사용처가 있기 때문입니다. 바로 제네릭 타입의 제약에 인터페이스를 명시했을 때 ref struct 타입의 사용이 가능해진다는 점입니다.
일례로 기존에는 다음과 같이 where 제약을 가하는 것이 불가능했지만,
// C# 12 이하에서는 ref strcut 타입은 인터페이스를 상속할 수 없으므로 PrintType<T>의 T 타입 인자로 사용할 수 없음
ref struct PrintType<T> where T : ILog
{
T _instance;
public PrintType(T instance)
{
_instance = instance; // ref struct 인스턴스를 보관하기 위해서는 PrintType<T> 자체도 ref struct 타입이어야 함
}
public void Log(TextWriter tw)
{
_instance.Log(tw); // 인터페이스로 형변환할 필요는 없으므로 interface를 상속한 ref struct 타입도 사용 가능
}
}
C# 13부터는 가능해진 것입니다. 또한, "ref struct" 자체에 대해서도 제네릭에 제약을 가할 수 있게 되었는데요, 일례로 위의 PrintType의 T 타입 인자로 ILog를 구현한 것 외에도 반드시 ref struct 유형만 가능하게 하려면 다음과 같이 "allows ref struct" 제약을 추가할 수 있습니다.
ref struct PrintType<T> where T : ILog, allows ref struct // allows ref struct 제약은 반드시 가장 마지막 위치에 명시해야 함
{
T _instance;
// ...[생략]...
}
위와 같은 경우, ILog를 구현한 타입이라고 해도 class나 일반 struct 타입이라면 PrintType<T>의 T 타입 인자로 사용할 수 없습니다.
참고로 아래의 공식 문서를 보면,
Restrictions for ref struct types that implement an interface
; https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/ref-struct#restrictions-for-ref-struct-types-that-implement-an-interface
인터페이스를 사용한 ref struct의 경우, 이후의 변화에 따라 1) 소스 레벨에서 컴파일 시 오류가 발생하거나 2) 바이너리 레벨에서 런타임 시 예외가 발생할 수 있으니 주의가 필요하다고 합니다.
예를 들면, 다음과 같이 인터페이스와 ref struct 구현체가 어셈블리를 나눠 존재한다고 가정해 보겠습니다.
// A_asm.dll
public interface ILog
{
void Log(TextWriter tw);
}
// B_asm.dll
public ref struct MyStruct : ILog
{
public void Log(TextWriter tw) => tw.WriteLine("MyStruct.Log");
}
저렇게 분리가 된 채로 관리가 되고 있는 상황에서, 저 상황을 모르는 A_asm.dll 개발자는 무심코 ILog 인터페이스에 기본 메서드를 추가할 수 있을 것입니다.
// A_asm.dll
public interface ILog
{
void Log(TextWriter tw);
int X => 0; // 기본 메서드 추가
}
그럼, 어느 순간 B_asm.dll을 유지/보수하던 개발자는 컴파일하는 순간 int X { get; }을 구현하지 않았다고 컴파일 오류가 발생할 것입니다.
또 다른 시나리오로는, 이미 잘 실행되고 있는 제품에 (기본 메서드 정의를 추가한) A_asm.dll을 업데이트해서 배포를 했다면, 그리고 이런 상황에서 어떤 식으로든 MyStruct.X를 접근하는 코드가 실행된다면 런타임 오류가 발생하게 됩니다.
물론 원칙상으로는, interface를 한번 구현했다면 철저하게 "계약(contact)"이라는 관점에서 그 구현을 바꿔서는 안 됩니다. 따라서, 위와 같은 경우 ILog2를 새로 만들어 제공했어야 합니다.
// A_asm.dll
public interface ILog
{
void Log(TextWriter tw);
}
public interface ILog2 : ILog
{
int X => 0; // 기본 메서드 추가
}
하지만 원칙이 그렇다 해도, 실제로는 저렇게 변화가 되는 것을 강제로 막는 장치가 없으므로 문제의 소지가 될 수 있음에 유의해야 합니다.
그나저나 이런 특수한 문법이 사용될 사례가 과연 어떤 것일까요? proposal 링크를 따라가보면 아래의 이슈가 나옵니다.
[API Proposal]: Utf8JsonReader should read from JsonNode #106047
; https://github.com/dotnet/runtime/issues/106047
예를 들어, 아래와 같은 코드는,
string dataJson = message.PayloadData.ToJsonString(); // typeof(PayloadData) == JsonNode
byte[] dataJsonBytes = Encoding.UTF8.GetBytes(dataJson);
var reader = new Utf8JsonReader(dataJsonBytes);
Utf8JsonReader를 초기화하는데 기존 JsonNode 인스턴스를 "문자열"로 바꾼 후 다시 "바이트 배열"로 변환하는 과정을 거치고 있습니다. 한 마디로, 쓸데없이 GC Heap을 사용하고 있는 것입니다.
그보다는, JsonNode 인스턴스를 바로 Utf8JsonReader로 전달해 초기화하는 것이 효율적일 텐데요, 질문자는 단순히 JsonNode를 인자로 받는 생성자를 Utf8JsonReader에 추가해 달라고 했지만, 답변자는 Utf8JsonReader에 (앞으로도 요구에 따라 늘어날지도 모르는) 고정된 타입 유형을 추가하기보다는, 그 중간을 추상화하는 것이 (시간이 좀 걸리더라도) 좀 더 나을 거라는 식으로 답변합니다.
가령 이런 식으로 구현하는 것인데,
public partial class JsonConverter<T>
{
public virtual T? Read<TReader>(ref TReader reader, Type type, JsonSerializerOptions options) where TReader : allow ref struct, IJsonReader => throw new NotImplementedException();
}
위의 TReader가 바로 Utf8JsonReader가 올 수 있는 타입 인자입니다. 그런데 막상 이런 유형으로 구현하고 싶어도 현재 Utf8JsonReader는 ref struct 타입이기 때문에 IJsonReader를 지정할 수 없고, 또한 TReader에 ref struct만 지정하게 강제할 수도 없습니다. 따라서 C# 13을 통해, 인터페이스를 상속할 수 있도록 만들고, 제네릭 제약에 신규로 allows ref struct 제약도 추가해 저 psudo 코드를 실제로 구현할 수 있게 만들고 싶은 듯합니다. (하지만 .NET 9 BCL의 Utf8JsonReader는 아직 어떠한 인터페이스도 상속하지 않고 있습니다.)
[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]