C# 9 record에 대한 토론

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

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

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

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

먼저 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; }
   }
   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의 깊은 복사 방법에 대해서 아시는 분이 계실까요?

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

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

좋아요 1

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

에러로 처리하고 싶으시면 .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)에도 흥미로는 글들이 많으니 살펴보세요