콘텐츠로 이동

C# Discriminated Union 패턴 구현

판별 합집합(Discriminated Union, DU)은 값이 여러 타입 중 하나임을 타입 시스템으로 표현하는 구조입니다. F#이나 Rust의 enum과 동일한 개념으로, C#에는 내장 문법이 없지만 레코드 계층, OneOf 라이브러리, 패턴 매칭으로 구현할 수 있습니다.


1. 레코드 계층 — 가장 관용적인 C# 방식

섹션 제목: “1. 레코드 계층 — 가장 관용적인 C# 방식”
// abstract record로 기반 타입 선언
abstract record Shape;
record Circle(double Radius) : Shape;
record Rectangle(double Width, double Height) : Shape;
record Triangle(double Base, double Height) : Shape;
// switch expression으로 소진(exhaustive) 매칭
double Area(Shape shape) => shape switch
{
Circle c => Math.PI * c.Radius * c.Radius,
Rectangle r => r.Width * r.Height,
Triangle t => 0.5 * t.Base * t.Height,
_ => throw new UnreachableException()
};
// 새 타입 추가 시 컴파일러가 경고 발생 (C# 9 이상)

abstract record Result<T>;
record Ok<T>(T Value) : Result<T>;
record Err<T>(string Message) : Result<T>;
// 헬퍼 메서드
static Result<T> Succeed<T>(T value) => new Ok<T>(value);
static Result<T> Fail<T>(string msg) => new Err<T>(msg);
// 사용
Result<int> Parse(string s) =>
int.TryParse(s, out int v)
? Succeed(v)
: Fail<int>($"'{s}'는 정수가 아닙니다");
var result = Parse("42");
string msg = result switch
{
Ok<int> ok => $"값: {ok.Value}",
Err<int> err => $"오류: {err.Message}",
_ => throw new UnreachableException()
};

3. Map/Bind — Railway-Oriented Programming

섹션 제목: “3. Map/Bind — Railway-Oriented Programming”
static class ResultExtensions
{
public static Result<U> Map<T, U>(
this Result<T> r, Func<T, U> f) => r switch
{
Ok<T> ok => new Ok<U>(f(ok.Value)),
Err<T> err => new Err<U>(err.Message),
_ => throw new UnreachableException()
};
public static Result<U> Bind<T, U>(
this Result<T> r, Func<T, Result<U>> f) => r switch
{
Ok<T> ok => f(ok.Value),
Err<T> err => new Err<U>(err.Message),
_ => throw new UnreachableException()
};
}
// 파이프라인 구성
Result<string> process = Parse("42")
.Map(v => v * 2)
.Bind(v => v > 50 ? Succeed($"큼: {v}") : Fail<string>("너무 작음"));

// NuGet: OneOf
using OneOf;
// 3개 타입의 합집합
OneOf<string, int, bool> value = "hello";
// Match로 모든 케이스 처리 (컴파일 타임 강제)
string result = value.Match(
s => $"문자열: {s}",
i => $"정수: {i}",
b => $"불리언: {b}"
);
// Switch
value.Switch(
s => Console.WriteLine(s.ToUpper()),
i => Console.WriteLine(i * 2),
b => Console.WriteLine(!b)
);
// Is/AsT0/AsT1/AsT2
if (value.IsT0)
string s = value.AsT0;

abstract record Option<T>;
record Some<T>(T Value) : Option<T>;
record None<T> : Option<T>;
static Option<T> From<T>(T? value) where T : class =>
value is not null ? new Some<T>(value) : new None<T>();
static Option<T> From<T>(T? value) where T : struct =>
value.HasValue ? new Some<T>(value.Value) : new None<T>();
// 사용
Option<User> FindUser(int id) =>
_db.TryGet(id, out var user) ? new Some<User>(user) : new None<User>();
string display = FindUser(1) switch
{
Some<User> u => u.Value.Name,
None<User> => "알 수 없음",
_ => throw new UnreachableException()
};

6. C# 12 — 타입 별칭과 미래 전망

섹션 제목: “6. C# 12 — 타입 별칭과 미래 전망”
// C# 12: using alias로 DU 타입명 간소화
using ParseResult = OneOf.OneOf<int, string>;
ParseResult TryParse(string s) =>
int.TryParse(s, out int v) ? v : s;
// C# 미래 (제안 중): 내장 union 타입
// union type Result = Ok(int) | Error(string); ← 아직 표준 미채택

// 레코드 기반 DU: 힙 할당 발생
// 고빈도 경로에서는 구조체 기반으로
readonly struct ValueResult<T> where T : struct
{
public readonly T Value;
public readonly string? Error;
public bool IsOk => Error is null;
public ValueResult(T value) { Value = value; Error = null; }
public ValueResult(string error) { Value = default; Error = error; }
}
// 힙 할당 없음, 스택에서 처리
var r = new ValueResult<int>(42);

C#에서 DU를 구현하는 가장 관용적인 방법은 abstract record + 자식 레코드 계층입니다. switch expression으로 소진 매칭을 하고, Map/Bind로 파이프라인을 구성하면 Railway-Oriented Programming 패턴을 안전하게 구현할 수 있습니다. 외부 라이브러리를 허용한다면 OneOf가 더 간결한 문법을 제공합니다.