record, init-only, with 식
값으로 비교하는 형식과, 고쳐 만든 사본입니다.
값으로 비교하는 형식
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 는 그 넷을 만들어 줍니다. 함께
들어온 init 과 with 는
만든 뒤에 바뀌지 않는 값을 다루기 위한 것입니다.
한 줄로 만들어집니다
아래 코드는 브라우저 안에서 실제로 실행됩니다. 고쳐서 눌러 보셔도 됩니다.
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);
코드는 고쳐서 실행해 볼 수 있습니다. 처음 누를 때만 실행기를 내려받느라 잠시 걸립니다. 적은 코드는 서버로 나가지 않습니다.
마지막 줄을 눈여겨보세요. 담긴 숫자가 똑같은데
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 전용이 아닙니다. 평범한
클래스에도 그대로 사용합니다. 위 예제의 Config 가
class 인 것이 그 뜻입니다.
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
내용으로 비교해야 한다면 Equals 와
GetHashCode 를 직접 적습니다.
둘은 반드시 함께 고칩니다. 한쪽만 고치면 사전에 담은 것을 다시 찾지
못합니다.
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(); } }
Equals 가 virtual 인 것에
주의하세요. 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 class 가 init 인 것과
다르므로, 바뀌지 않게 하려면 readonly 를 붙입니다.
record struct Size(int W, int H); // W·H 를 나중에 바꿀 수 있습니다 readonly record struct Size2(int W, int H); // 바꿀 수 없습니다
그냥 struct 도 Equals 는 값으로
비교합니다. 다만 == 는 없습니다.
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 응답처럼 만들고 나면 고치지 않는 것들입니다.
이 문법, 몇 버전부터 사용할 수 있나요
- 9.0record · init · with
- 10.0record struct · readonly record struct
- 11.0required
셋은 따로 들어온 것이 아니라 한 묶음입니다.
record 가 값으로 비교하게 하고,
init 이 만든 뒤에 바뀌지 않게 하고,
with 가 바꾸는 대신 사본을 만들게 합니다.
하나만 가져다 사용하면 반쪽이 됩니다. init 없이
record 를 만들면 값으로 비교하는데 값이 바뀌어,
사전에 담아 둔 것을 다시 찾지 못하는 일이 생깁니다.
직접 해보기
시작일과 종료일을 담는 Period 를 만들어보세요.
같은 날짜면 같은 것으로 비교되어야 하고, 시작일이 종료일보다 늦으면 만들
수 없어야 합니다. 며칠인지 알려 주는 Days 도
두세요.
아래 코드는 방금 담은 것을 다시 찾지 못합니다. 까닭을 설명하고 고쳐보세요.
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)); // ?
record는Equals·GetHashCode·==·ToString·with를 만들어 줍니다.- 비교도 복사도 얕습니다. 배열이나 목록을 담으면 내용이 같아도 다른 것입니다.
Equals를 직접 적으면GetHashCode도 함께 고칩니다. 한쪽만 고치면 사전에서 못 찾습니다.- 값이 같아도 형식이 다르면 다른 것입니다.
init·required는record전용이 아닙니다. 평범한 클래스에도 사용합니다.record struct의 위치 매개 변수는 기본이 바뀝니다.readonly를 붙이세요.- 번호로 구분하는 것에는 사용하지 않습니다. 같은 것의 두 시점이 다른 것이 됩니다.
C# 버전별 변경 이력 에서 각 버전이 무엇을 더했는지 볼 수 있습니다.