C#LAB
C# 4.5 · 4부. 고급 문법

record, init-only, with 식

값으로 비교하는 형식과, 고쳐 만든 사본입니다.

예상 학습 시간 20분 실행 단추가 있는 예제는 고쳐서 실행해 볼 수 있습니다 난이도 고급
개념 설명

값으로 비교하는 형식

2.1에서 만든 클래스는 같은 것인지를 자리로 판단합니다. 값이 전부 같아도 따로 만들었으면 다른 것입니다.

class Point { public int X, Y; }

var a = new Point { X = 1, Y = 2 };
var b = new Point { X = 1, Y = 2 };

a == b;   // False

좌표·금액·기간처럼 값이 같으면 같은 것으로 다루고 싶은 것이 있습니다. 그러려면 Equals·GetHashCode· ==·ToString 을 모두 다시 적어야 했고, 멤버를 하나 추가할 때마다 네 자리를 함께 고쳐야 했습니다.

record 는 그 넷을 만들어 줍니다. 함께 들어온 initwith만든 뒤에 바뀌지 않는 값을 다루기 위한 것입니다.

최소 예제 · 편집 가능

한 줄로 만들어집니다

아래 코드는 브라우저 안에서 실제로 실행됩니다. 고쳐서 눌러 보셔도 됩니다.

Program.cs
var a = new Point(1, 2);
var b = new Point(1, 2);

Console.WriteLine(a);                        // ToString 이 만들어져 있습니다
Console.WriteLine(a == b);                   // 값으로 비교합니다
Console.WriteLine(ReferenceEquals(a, b));    // 자리는 다릅니다

// 하나만 바꾼 사본을 만듭니다. 원본은 그대로입니다.
var c = a with { Y = 99 };
Console.WriteLine(c);
Console.WriteLine(a);

// 분해도 만들어져 있습니다.
var (x, y) = a;
Console.WriteLine($"x={x} y={y}");

// 참조 형식을 담으면 그 안까지는 보지 않습니다.
var m = new Bag([1, 2, 3]);
var n = new Bag([1, 2, 3]);
Console.WriteLine(m == n);

record Point(int X, int Y);
record Bag(int[] Items);
출력
Point { X = 1, Y = 2 } True False Point { X = 1, Y = 99 } Point { X = 1, Y = 2 } x=1 y=2 False

코드는 고쳐서 실행해 볼 수 있습니다. 처음 누를 때만 실행기를 내려받느라 잠시 걸립니다. 적은 코드는 서버로 나가지 않습니다.

마지막 줄을 눈여겨보세요. 담긴 숫자가 똑같은데 False 입니다. 이 단원에서 가장 자주 걸리는 자리이고, 아래에서 다시 다룹니다.

상세 사용법

두 가지 적는 법

위치로 — 짧게
record Point(int X, int Y);

// 생성자·속성·분해가
// 함께 만들어집니다
var p = new Point(1, 2);
var (x, y) = p;
이름으로 — 늘어날 때
record Point {
    public int X { get; init; }
    public int Y { get; init; }
}

var p = new Point { X = 1, Y = 2 };

위치로 적으면 순서가 계약이 됩니다. 나중에 매개 변수를 중간에 끼워 넣으면 부르는 자리가 조용히 어긋납니다. 멤버가 서넛을 넘어가면 이름으로 적는 편이 읽기도 좋고 고치기도 안전합니다.

만들어지는 것

무엇이어떻게
ToString()Point { X = 1, Y = 2 }
Equals · ==멤버를 하나씩 비교합니다
GetHashCode()같은 값이면 같습니다. 사전의 키로 사용할 수 있습니다
with일부만 바꾼 사본을 만듭니다
Deconstruct위치로 적었을 때만 만들어집니다

init 과 required

init만들 때만 담을 수 있는 속성입니다. readonly 와 달리 개체 초기화자로 담을 수 있고, 그 뒤로는 막힙니다.

class Config { public required string Host { get; init; } }

var cfg = new Config { Host = "localhost" };   // 됩니다
cfg.Host = "다른 값";

// error CS8852: 초기값 전용 속성 또는 인덱서 'Config.Host'은(는) 개체 이니셜라이저
// 또는 인스턴스 생성자나 'init' 접근자의 'this' 또는 'base'에만 할당할 수 있습니다.

required(C# 11)는 빠뜨리면 컴파일되지 않게 합니다. 생성자를 여러 벌 두지 않고도 필수 항목을 강제할 수 있습니다.

var bad = new Config();

// error CS9035: 필수 구성원 'Config.Host'은(는) 개체 이니셜라이저 또는
// 특성 생성자에서 설정해야 합니다.

둘은 record 전용이 아닙니다. 평범한 클래스에도 그대로 사용합니다. 위 예제의 Configclass 인 것이 그 뜻입니다.

with 는 얕은 복사입니다

with 는 멤버를 그대로 옮겨 담고 적은 것만 바꿉니다. 참조 형식은 가리키는 곳까지 같습니다.

record Holder(List<int> Items, string Name);

var m = new Holder([1, 2], "원본");
var n = m with { Name = "사본" };

n.Items.Add(3);

Console.WriteLine(m.Items.Count);                  // 3   사본에 넣었는데 원본도 3
Console.WriteLine(ReferenceEquals(m.Items, n.Items));  // True

바뀌지 않는 값을 담을 생각이라면 안에 담는 것도 바뀌지 않는 것이어야 합니다. List<T> 대신 ImmutableArray<T>IReadOnlyList<T> 를 담고, 만들 때 복사해 두는 편이 안전합니다.

비교도 얕습니다

최소 예제의 마지막 줄이 여기 걸립니다. 만들어지는 Equals멤버마다 그 형식의 Equals 를 부릅니다. 배열의 Equals 는 자리를 보므로, 내용이 같아도 다른 것입니다.

record Bag(int[] Items);

new Bag([1, 2, 3]) == new Bag([1, 2, 3]);   // False

내용으로 비교해야 한다면 EqualsGetHashCode 를 직접 적습니다. 둘은 반드시 함께 고칩니다. 한쪽만 고치면 사전에 담은 것을 다시 찾지 못합니다.

record Bag(int[] Items) {
    public virtual bool Equals(Bag? other)
        => other is not null && Items.SequenceEqual(other.Items);

    public override int GetHashCode() {
        var hash = new HashCode();
        foreach (var i in Items) hash.Add(i);
        return hash.ToHashCode();
    }
}

Equalsvirtual 인 것에 주의하세요. record 가 만드는 것과 같은 모양이어야 == 도 함께 바뀝니다.

상속하면 형식까지 봅니다

record값이 같아도 형식이 다르면 다른 것으로 봅니다. 안에 감춰진 EqualityContract 가 그 일을 합니다.

record Point(int X, int Y);
record Point3D(int X, int Y, int Z) : Point(X, Y);

Point a = new Point3D(1, 2, 0);
Point b = new Point(1, 2);

a.Equals(b);   // False   X·Y 가 같아도 형식이 다릅니다

Console.WriteLine(a);   // Point3D { X = 1, Y = 2, Z = 0 }

평범한 클래스에서 자주 나는 실수를 막아 줍니다. GetType() 대신 is 로 검사하도록 적으면, 파생이 기반과 같다고 나오면서 대칭이 깨집니다.

record class 와 record struct

무엇을어떤 때
record참조 형식입니다. record class 와 같습니다
record struct값 형식입니다(C# 10). 작고 담아 다닐 것
readonly record struct담은 뒤 바꾸지 않을 값 형식. 대개 이것을 선택하면 됩니다

record struct 의 위치 매개 변수는 기본이 get·set 입니다. record classinit 인 것과 다르므로, 바뀌지 않게 하려면 readonly 를 붙입니다.

record struct Size(int W, int H);            // W·H 를 나중에 바꿀 수 있습니다
readonly record struct Size2(int W, int H);   // 바꿀 수 없습니다

그냥 structEquals 는 값으로 비교합니다. 다만 == 는 없습니다.

struct Plain(int W, int H) { public int W = W, H = H; }

new Plain(3, 4).Equals(new Plain(3, 4));   // True
new Plain(3, 4) == new Plain(3, 4);

// error CS0019: '==' 연산자는 'Plain' 및 'Plain' 형식의 피연산자에 적용할 수 없습니다.

record 를 사용하지 않을 자리

이럴 때까닭
계속 고쳐 나가는 것바뀌지 않는 값을 위한 문법입니다. 고칠 것이라면 이점이 없습니다
번호로 구분하는 것같은 사람의 두 시점을 다른 것으로 보게 됩니다. 번호가 같으면 같은 것이어야 합니다
참조 형식을 잔뜩 담는 것비교도 복사도 얕아 기대와 어긋납니다
큰 값을 자주 넘김record struct 는 넘길 때마다 복사됩니다

잘 맞는 자리는 화면과 화면 사이에 오가는 값, 설정, 계산 결과, API 응답처럼 만들고 나면 고치지 않는 것들입니다.

버전 배지

이 문법, 몇 버전부터 사용할 수 있나요

1.0
3.0
6.0
8.0
9.0
10.0
11.0
12.0
14.0
  • 9.0record · init · with
  • 10.0record struct · readonly record struct
  • 11.0required
C# 9.0+

셋은 따로 들어온 것이 아니라 한 묶음입니다. record 가 값으로 비교하게 하고, init 이 만든 뒤에 바뀌지 않게 하고, with 가 바꾸는 대신 사본을 만들게 합니다. 하나만 가져다 사용하면 반쪽이 됩니다. init 없이 record 를 만들면 값으로 비교하는데 값이 바뀌어, 사전에 담아 둔 것을 다시 찾지 못하는 일이 생깁니다.

실습 문제

직접 해보기

1. 기간을 값으로 다루기 난이도 하

시작일과 종료일을 담는 Period 를 만들어보세요. 같은 날짜면 같은 것으로 비교되어야 하고, 시작일이 종료일보다 늦으면 만들 수 없어야 합니다. 며칠인지 알려 주는 Days 도 두세요.

위치로 적은 record 에도 본문을 둘 수 있습니다. 검사는 생성자에서 하는데, 위치 매개 변수의 생성자 본문은 record Period(…) { … } 안에 따로 적지 않고 초기화자나 this 생성자로 처리합니다.
readonly record struct Period { public DateOnly From { get; } public DateOnly To { get; } public int Days => To.DayNumber - From.DayNumber + 1; public Period(DateOnly from, DateOnly to) { if (to < from) throw new ArgumentException("종료일이 시작일보다 빠릅니다.", nameof(to)); From = from; To = to; } public void Deconstruct(out DateOnly from, out DateOnly to) => (from, to) = (From, To); } // 값이 같으면 같은 것입니다 var a = new Period(new(2026, 1, 1), new(2026, 1, 31)); var b = new Period(new(2026, 1, 1), new(2026, 1, 31)); Console.WriteLine(a == b); // True Console.WriteLine(a.Days); // 31 // 위치로 적으면 검사를 넣을 자리가 마땅치 않습니다. 검사가 필요하면 // 이렇게 이름으로 적고 생성자를 직접 두는 편이 읽기 좋습니다. // 분해가 필요하면 Deconstruct 를 손으로 둡니다.
2. 사전에서 사라지는 키 고치기 난이도 중

아래 코드는 방금 담은 것을 다시 찾지 못합니다. 까닭을 설명하고 고쳐보세요.

record Tags(List<string> Items);

var map = new Dictionary<Tags, int>();
var key = new Tags(["a", "b"]);

map[key] = 1;

Console.WriteLine(map.ContainsKey(new Tags(["a", "b"])));   // False

key.Items.Add("c");
Console.WriteLine(map.ContainsKey(key));                     // ?
문제가 둘입니다. 하나는 List 의 비교가 자리를 본다는 것이고, 다른 하나는 담은 뒤에 그 목록이 바뀔 수 있다는 것입니다. 사전은 담을 때의 해시로 자리를 정합니다.
// 첫 번째 False — record 의 비교는 멤버마다 그 형식의 Equals 를 부릅니다. // List 의 Equals 는 자리를 보므로 내용이 같아도 다른 것입니다. // 두 번째는 True 가 나옵니다. 목록의 해시가 자리로 정해지는데 // 안에 무엇을 넣어도 자리는 그대로이기 때문입니다. // 우연히 찾아지는 것이고, 내용으로 비교하도록 고치면 못 찾게 됩니다. // 사전은 담을 때의 해시로 자리를 정하므로, 담은 뒤에 해시가 달라지면 // 그 항목은 사라진 것이 됩니다. // 고치기 — 담을 때 복사하고, 내용으로 비교합니다. record Tags { private readonly string[] _items; public IReadOnlyList<string> Items => _items; public Tags(IEnumerable<string> items) => _items = [.. items]; public virtual bool Equals(Tags? other) => other is not null && _items.SequenceEqual(other._items); public override int GetHashCode() { var hash = new HashCode(); foreach (var s in _items) hash.Add(s); return hash.ToHashCode(); } } // 이제 담긴 배열을 밖에서 고칠 수 없으므로 해시가 달라지지 않습니다. // 사전의 키로 사용할 것은 담은 뒤에 바뀌지 않아야 합니다.
요약
  • recordEquals·GetHashCode·==·ToString·with 를 만들어 줍니다.
  • 비교도 복사도 얕습니다. 배열이나 목록을 담으면 내용이 같아도 다른 것입니다.
  • Equals 를 직접 적으면 GetHashCode 도 함께 고칩니다. 한쪽만 고치면 사전에서 못 찾습니다.
  • 값이 같아도 형식이 다르면 다른 것입니다.
  • init·requiredrecord 전용이 아닙니다. 평범한 클래스에도 사용합니다.
  • record struct 의 위치 매개 변수는 기본이 바뀝니다. readonly 를 붙이세요.
  • 번호로 구분하는 것에는 사용하지 않습니다. 같은 것의 두 시점이 다른 것이 됩니다.

C# 버전별 변경 이력 에서 각 버전이 무엇을 더했는지 볼 수 있습니다.