C# 9 record에 대한 토론

C# 9.0 에서 추가된 record type 형식에 대해 다양한 토론을 나눠보면 어떨까 합니다.

이세상에 정답이란 것은 없으므로 자신의 주장에 일관성만 유지해서 나누고 모순이 발견되거나 부족한점이 있다면 정정하고, 다른 분의 부족함이 보인다 하더라도 너그럽게 본인의 주장만 잘 전개한다면 어떤 내용이던 합을 향해서 토론할 수 있을 것 같아 글을 등록합니다.

토론 주제는 먼저 제가 목록으로 뽑아보겠습니다. 댓글로 추가할 토론 주제를 말씀 주시면 본문에 추가수정해볼께요.

  1. record는 어느 영역에 적극적으로 쓰일 수 있을까?
  2. record 설계시 왜 용법이 불변 아닌것도 허용 설계했을까?
  3. record을 목적에 맞게 목적과 코드 매칭 예시
  4. record를 쓸 때 주의할 점 - 오용, 오동작, 성능저하
1개의 좋아요

먼저 record가 불변하다고 소개하는 것은 정확하지 않는것 같습니다. 왜냐하면 불변하지 않게 만들 수도 있거든요.

record NamedImmutableObject(string Name);

으로 정의하면 NamedObject record는 불변하지만, 다음과 같이 정의 하면 불변하지 않습니다.

record NamedObejct
{
   public String { get; set; }
}

record 또한 참조형을 만들기 때문에, class 유사한 특징이 있습니다. 심지어 null도 대입이 가능한데요. class 와 다른 점은 위의 경우처럼 불변개체를 만들 수 있는 쉬운 용법을 제공한다는 것값처럼 쓸 수 있도록 비교연산자 등을 언어에서 대신 구현해주는 정도입니다.

1개의 좋아요

record를 최근에 적극적으로 사용하면서 편리한 점을 몇가지 공유하자면, 당연히 단일 값이 아닌 복합 값 예를 들어 정보를 얻기 위한 메소드의 반환값으로 사용하면 좋습니다. tuple의 명세 버젼이랄까요. tuple도 모듈단위 내부용으로 아주 훌륭합니다. 그런데 모듈 밖에서 모듈의 특정 기능을 제공하고자 할 때는 tuple 보다는 record가 좋다고 생각합니다.

이런 메소드의 반환값으로 정보를 반환할 때는 변할 수 있는 값 보다는 변하지 않는 불변이 좋습니다. 값을 조작해서 오용될 여지를 아예 막을 수 있기 때문인데요. record는 그런 목적으로 위의 용법 처럼

record Point(int X, int Y);

이렇게 쉽게 정의 하는 용법을 제공해주는 것 같습니다. 실제로 코딩해보니까 좀 더 문제 해결에만 집중할 수 있었던 것 같습니다.

1개의 좋아요

여러가지 테스트 중에 상속을 통한 속성 결합을 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로 데이터의 레코드를 표현할 때 동일한 관점의 장점을 활용할 수 있습니다.

1개의 좋아요
   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; }
   }
   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
   m1.C.Value = 2;
   Console.WriteLine($"{m1.C}, {m2.C}");

그러자 좀 불편한 결과를 확인할 수 있었습니다. record라 할지라도 불변하지 않으면 class와 동일한 오동작 코딩을 할 여지가 생깁니다.

결국에는 with를 이용한 복사는 단지 변경 속성을 쉽게 쓸수 있게 할 뿐이지 속성이 참조형일 경우 class던 record던 얕은 복사를 한다는 점입니다.

record의 깊은 복사 방법에 대해서 아시는 분이 계실까요?

1개의 좋아요

record에 대한 설명중 불변하다고 소개하는건 확실히 잘못되었다고 생각합니다. 말씀하신 용례처럼 예전 클래스 쓰듯이 선언하면 불변이 적용이 안되니까요.
public String value { get; set; } 이 아니라 public String value { get; init; } 으로 되어있는데, 굳이 따지면 record가 불변을 보장해주는게 아니라 property의 init 키워드가 보장해주죠.

null 대입의 문제는 nullable과 밀접한 관계로 보입니다. 기존 대부분의 라이브러리들이 nullable을 적용하지 않았다보니 뭔가 만들다만 느낌이 나는데, 프로젝트 전체에 nullable 규칙을 적용하고 property의 속성이나 옵션들을 다 버리고 데이터만 넣는 클래스로 관리하니 record가 확실히 코드들에 규칙을 강력하게 부여한다는 느낌은 받았어요

3개의 좋아요

의견 감사합니다. 말씀하신 것 처럼 nullable 규칙을 적용해야 record가 좀 더 다른 무엇으로 다가오는 것 같습니다. 저도 몇일전부터 nullable 규칙을 적용하고 코딩을 시작했습니다. 컴파일 오류가 아닌 경고를 주는것은 아쉽지만 하위호환성도 중요하므로 어쩔 수 없는 선택인것 같습니다.

1개의 좋아요

에러로 처리하고 싶으시면 .editorconfig 에서 아래값을 추가하시면 됩니다.

[*.cs]

# CS8600: null 리터럴 또는 가능한 null 값을 null을 허용하지 않는 형식으로 변환하는 중입니다. dotnet_diagnostic.CS8600.severity = error)

vs에서 nullable 이 켜진상태로 string s = null; 처럼 null값 할당하면 경고가 뜨는곳에서 잠재적 수정사항 → 심각도 구성-> 에러 선택하시면 자동으로 만들어져요.

파일이름이 .editorconfig 라서 cli등으로 빌드하면 적용이 안될것같은 이름이지만 dotnet build로 실행해도 에러뜨면서 컴파일 취소됩니다.

3개의 좋아요

Top Level Program 및 Recrod를 이용한 흥미있는 아티클입니다.

참고로 C# Advent (csadvent.christmas)에도 흥미로는 글들이 많으니 살펴보세요

1개의 좋아요

방금 CommunityToolkit 8 의 Messenger에 record를 사용해봤는데, 문제없이 잘 돌았습니다.

Messenger에 사용되는 제네릭 타입 제약조건이 class로 걸려있고, 아무런 Action도 없는 POCO Type의 Class인 record가 Messenger에 적합하다고 생각이 되어 사용해봤습니다.

3개의 좋아요

레코드는 IEquatable를 확장하고 있어, 같은 개체간의 서로를 비교하는 목적이 없다면
순수 값만 사용되는 POCO Class라면., 제 생각은 그냥 레코드 보단 일반 클래스로 하는것이 더 적합할 것 같습니다.

제가 생각하는 레코드를 사용하는 상황은

  1. 레코드 개체가 가지는 모든 속성이 초기화 후 읽기 전용 속성(init set)일때 그것을 간편하게 생성자로 처리하고자 하는 경우 (설탕문법 사용)
  2. 개체간의 비교처리를 위해 GetHashCode, Equals 사용이 필요한 경우

라고 생각합니다.

혹시 메신저로 사용되는 클래스를 레코드 타입으로 했을때 클래스로 사용하는 것 보다 더 나은 이점이 무엇이 있을까요?

3개의 좋아요

deconstructor 자동 구현?

구조분해 문법을 적극적으로 사용하는 상황에서는 완소 기능이기는 합니닷 ㅋㅅㅋ

4개의 좋아요

아 레코드가 Deconstruct도 자동 구현 되어 지나보군요?

저는 IEquatable 만 포함 하고 있는 줄 알았는데

외부에 노출되는 속성이 많은경우 Deconstruct메서드까지 자동 구현되는거라면

확실히 편하긴 하겠네요

4개의 좋아요

오우 저는 그냥 코드를 줄일 수 있다는 생각으로 접근했었습니다!

그렇게까지 깊이는 생각을 못했습니다.

놓친 부분 짚어주셔서 감사합니다~

3개의 좋아요

안녕하세요. 좀 지난 글이기도 하고 이미 내용을 아실수도 있지만 공유차원에서 댓글 답니다.

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 생성자로 호출하는데 이 때 필드의 값만 대입하기 때문에 얕은 복사가 일어나게됩니다.

[CompilerGenerated]
public virtual MustPredictableRecord <Clone>$()
{
    return new MustPredictableRecord(this);
}

[CompilerGenerated]
protected MustPredictableRecord(MustPredictableRecord original)
{
    <A>k__BackingField = original.<A>k__BackingField;
    <B>k__BackingField = original.<B>k__BackingField;
    <C>k__BackingField = original.<C>k__BackingField;
}

깊은 복사를 하기 위해서는 this를 받는 생성자를 다음과 같이 재정의 해줘야하는데요.

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 {};
    }
}
4개의 좋아요

이런 건… 어디서 공부하시나요 ㅋㅋㅋㅋ :+1:

2개의 좋아요