먼저 record가 불변하다고 소개하는 것은 정확하지 않는것 같습니다. 왜냐하면 불변하지 않게 만들 수도 있거든요.
record NamedImmutableObject(string Name);
으로 정의하면 NamedObject record는 불변하지만, 다음과 같이 정의 하면 불변하지 않습니다.
record NamedObejct
{
public String { get; set; }
}
record 또한 참조형을 만들기 때문에, class 유사한 특징이 있습니다. 심지어 null도 대입이 가능한데요. class 와 다른 점은 위의 경우처럼 불변개체를 만들 수 있는 쉬운 용법을 제공한다는 것과 값처럼 쓸 수 있도록 비교연산자 등을 언어에서 대신 구현해주는 정도입니다.
record를 최근에 적극적으로 사용하면서 편리한 점을 몇가지 공유하자면, 당연히 단일 값이 아닌 복합 값 예를 들어 정보를 얻기 위한 메소드의 반환값으로 사용하면 좋습니다. tuple의 명세 버젼이랄까요. tuple도 모듈단위 내부용으로 아주 훌륭합니다. 그런데 모듈 밖에서 모듈의 특정 기능을 제공하고자 할 때는 tuple 보다는 record가 좋다고 생각합니다.
이런 메소드의 반환값으로 정보를 반환할 때는 변할 수 있는 값 보다는 변하지 않는 불변이 좋습니다. 값을 조작해서 오용될 여지를 아예 막을 수 있기 때문인데요. record는 그런 목적으로 위의 용법 처럼
record Point(int X, int Y);
이렇게 쉽게 정의 하는 용법을 제공해주는 것 같습니다. 실제로 코딩해보니까 좀 더 문제 해결에만 집중할 수 있었던 것 같습니다.
여러가지 테스트 중에 상속을 통한 속성 결합을 record에서 지원하면 좋겠다 싶어서 테스트해봤는데 당연히 안되는 군요
record NamedObject(string Name);
record Point(int X, int Y);
record NamedPoint(string Name, int X, int Y) : NamedObject(Name), Point(X, Y);
이유는 당연히 C# 언어가 다중상속을 지원하지 않기 때문입니다. 기능적인 측면에서는 속성 결합이 레코드 관점에서는 굉장히 유용한데요 아쉽네요.
이것을 보완하는 방법은 역시 인터페이스를 이용하는 방법 뿐입니다.
// interface INamed(string Name); // interface는 불변속성을 간단하게 정의하는 용법은 지원하지 않음
interface INamed
{
string Name { get; }
}
interface IPoint
{
int X { get; }
int Y { get; }
}
record NamedPoint(string Name, int X, int Y) : INamed, IPoint;
정상 컴파일 되고 사용하는데 문제가 없습니다. interface도 record처럼 간단한 정의 용법을 제공해주면 좋겠다는 생각입니다.
이제 record의 특정 속성이 특정 인터페이스의 속성이라는 것을 구분할 수 있게 되었습니다. 인터페이스의 메소드가 모듈간 약속을 통해 구조를 단순화 하는것 처럼 record로 데이터의 레코드를 표현할 때 동일한 관점의 장점을 활용할 수 있습니다.
var s1 = new StrangeRecord
{
A = 10,
B = "NAME1",
C = new object()
};
var s2 = s1 with { };
Console.WriteLine(s1 == s2); // True
Console.WriteLine(s1.C == s2.C); // True
Console.WriteLine(ReferenceEquals(s1.C, s2.C)); // True
record StrangeRecord
{
public int A { get; init; }
public string B { get; init; }
public object C { get; init; }
}
record의 with 용법은 record의 object 속성까지 어떻게 하지는 못합니다. 그런 메커니즘이 없기 때문인데요. record에 class로 만든 속성을 사용할 때 오용하지 않도록 주의해야 할 점인것 같습니다.
var m1 = new MustPredictableRecord
{
A = 10,
B = "NAME2",
C = new(10)
};
var m2 = m1 with { };
Console.WriteLine(ReferenceEquals(m1, m2)); // False
Console.WriteLine(m1 == m2); // True
Console.WriteLine(m1.C == m2.C); // True
Console.WriteLine(ReferenceEquals(m1.C, m2.C)); // True
record IntValue(int Value);
record MustPredictableRecord
{
public int A { get; init; }
public string B { get; init; }
public IntValue C { get; init; }
}
음 이건 좀 당황스럽습니다. InvValue는 record이고 깊은 복사를 기대했는데 말이죠. 그런데 생각해보면, 값이 같은 불변개체의 경우 굳이 새로운 인스턴스를 생성할 필요가 없기 때문에 문제 없는 동작이 됩니다.
IntValue record를 값이 변할 수 있도록 다음과 같이 고쳐 보고 테스트를 진행해보았습니다.
record IntValue
{
public IntValue(int value) => Value = value;
public int Value { get; set; }
}
record에 대한 설명중 불변하다고 소개하는건 확실히 잘못되었다고 생각합니다. 말씀하신 용례처럼 예전 클래스 쓰듯이 선언하면 불변이 적용이 안되니까요.
public String value { get; set; } 이 아니라 public String value { get; init; } 으로 되어있는데, 굳이 따지면 record가 불변을 보장해주는게 아니라 property의 init 키워드가 보장해주죠.
null 대입의 문제는 nullable과 밀접한 관계로 보입니다. 기존 대부분의 라이브러리들이 nullable을 적용하지 않았다보니 뭔가 만들다만 느낌이 나는데, 프로젝트 전체에 nullable 규칙을 적용하고 property의 속성이나 옵션들을 다 버리고 데이터만 넣는 클래스로 관리하니 record가 확실히 코드들에 규칙을 강력하게 부여한다는 느낌은 받았어요
의견 감사합니다. 말씀하신 것 처럼 nullable 규칙을 적용해야 record가 좀 더 다른 무엇으로 다가오는 것 같습니다. 저도 몇일전부터 nullable 규칙을 적용하고 코딩을 시작했습니다. 컴파일 오류가 아닌 경고를 주는것은 아쉽지만 하위호환성도 중요하므로 어쩔 수 없는 선택인것 같습니다.
안녕하세요. 좀 지난 글이기도 하고 이미 내용을 아실수도 있지만 공유차원에서 댓글 답니다.
record에서 with 용법 사용 시 내부적으로 재정의된 Clone 메서드를 호출하고있습니다.
var m1 = new MustPredictableRecord(10, "Name2", new(10));
var m2 = m1 with { };
--> // sharplab.io 사이트 이용하여 complie된 내용 확인
MustPredictableRecord mustPredictableRecord = new MustPredictableRecord(10, "Name2", new IntValue(10));
MustPredictableRecord mustPredictableRecord2 = mustPredictableRecord.<Clone>$();
재정의된 Clone 메서드를 확인해보면 this로 아규먼트로 넘겨 new 생성자로 호출하는데 이 때 필드의 값만 대입하기 때문에 얕은 복사가 일어나게됩니다.
record MustPredictableRecord(int A, string B, IntValue C)
{
public MustPredictableRecord(MustPredictableRecord other)
{
this.A = other.A;
this.B = other.B;
this.C = other.C with {};
}
}
-->
public MustPredictableRecord(MustPredictableRecord other)
{
A = other.A;
B = other.B;
C = other.C.<Clone>$();
}
위와 같이 재정의한 후 실행결과를 살펴보면 서로 다른 주소를 참조하고 있는 결과를 얻을 수 있습니다.
using System;
var m1 = new MustPredictableRecord(10, "Name2", new(10));
var m2 = m1 with { };
Console.WriteLine(object.ReferenceEquals(m1, m2)); // False
Console.WriteLine(m1 == m2); // True
Console.WriteLine(m1.C == m2.C); // True
Console.WriteLine(object.ReferenceEquals(m1.C, m2.C)); // here! --> False <--
record IntValue(int Value);
record MustPredictableRecord(int A, string B, IntValue C)
{
public MustPredictableRecord(MustPredictableRecord other)
{
this.A = other.A;
this.B = other.B;
this.C = other.C with {};
}
}