Welcome To C# 9.0 (번역)

WSong·2020년 6월 29일
0
post-thumbnail

Intro


C# 9.0의 새 기능들에 관한 마이크로소프트 블로그의 글을 번역했다. 원문은 Welcome to C# 9.0

Welcome to C# 9.0


C# 9.0이 그 모양을 갖춰나가고 있다. 우리가 이 언어의 새로운 버전에 추가하려는 일부 주요 기능들에 대해 이야기하고자 한다.

C#의 새 버전마다 우리는 코딩을 할 때 일반적으로 더 명확하고 간단하게 할 수 있게 하기 위해 힘썼고, C# 9.0도 예외는 아니다. 특히 이번에는 간결하고 불변하는 데이터 형태를 표현하기 위해 힘썼다.

이제 알아보자!

초기화 전용 속성


객체(Object) 이니셜라이저(initializer)는 꽤나 멋진 기능이다. 사용자가 아주 유연하기 읽기 쉬운 형태로 객체를 생성할 수 있게 해주고, 특히 객체를 트리처럼 중첩해서 한 번에 생성하려고 할 때 유용하다. 간단한 예제를 보자.

new Person
{
    FirstName = "Scott",
    LastName = "Hunter"
}

객체 이니셜라이저는 클래스를 만드는 사람이 생성자에 관련된 많은 상용구(boilerplate - 보일러플레이트)를 작성하지 않아도 되게 해준다. 그냥 속성만 몇 개 작성하면 된다!

public class Person
{
    public string FirstName { get; set; }
    public string LastName { get; set; }
}

오늘날의 큰 한계 중 하나는 이 속성들이 반드시 수정 가능(mutable)해야 한다는 것이다. 그 동작 방식이 객체의 생성자를 호출하고 그 다음에 속성을 대입하기 때문이다. 예제의 경우 빈 생성자를 호출할 것이다.

초기화 전용 속성은 이 문제를 해결해준다. init 접근자는 set 접근자의 변형으로 객체 초기화 도중에만 불릴 수 있다.

public class Person
{
    public string FirstName { get; init; }
    public string LastName { get; init; }
}

이렇게 정의하면 맨 위 예제처럼 초기화 하는 것은 여전히 유효하지만 그 이후에 FirstName이나 LastName에 값을 대입하는 것은 에러를 일으킨다.

Init 접근자와 readonly 값들

init 접근자는 초기화 도중에만 불릴 수 있기 때문에 생성자 안에서 동작하는 것과 동일하게 클래스의 readonly 속성인 값들을 수정하는 것이 가능하다.

public class Person
{
    private readonly string firstName;
    private readonly string lastName;
    
    public string FirstName 
    { 
        get => firstName; 
        init => firstName = (value ?? throw new ArgumentNullException(nameof(FirstName)));
    }
    public string LastName 
    { 
        get => lastName; 
        init => lastName = (value ?? throw new ArgumentNullException(nameof(LastName)));
    }
}

레코드(Record)


초기화 전용 속성은 개별 속성을 수정 불가능(immutable)하게 만드는데 유용하다. 만약 객체 전체가 수정 불가능해 값(value)처럼 동작하기를 원한다면 객체를 Record로 선언하는 것을 고려해볼 수 있다.

public data class Person
{
    public string FirstName { get; init; }
    public string LastName { get; init; }
}

클래스의 data 키워드는 클래스를 레코드로 만든다. 이렇게 정의된 클래스는 값과 비슷한 몇 가지 특징을 가지게 된다. 일반적으로 레코드는 객체보단 값(데이터)으로 보여야 한다. 내부적으로 수정 가능한 상태를 가지고 있지 않아야 하며, 새로운 상태를 표현하기 위해 새 레코드를 생성한다. 데이터는 그 내용 자체로 하나의 고유한 값이 된다.

with 표현

변경이 불가능한 데이터로 작업을 할 때 새로운 상태를 표현하기 위한 일반적인 방법은 기존의 값을 이용해 새 값을 만드는 것이다. 예를 들어 예제 속 Person이 성을 바꾸려고 한다면 기존 객체에서 LastName만 다른 값으로 바꾼 복사본을 생성할 것이다. 이러한 방법은 보통 비파괴적 변경(non-destructive mutation)이라고 불린다. 사람 자체를 지속적으로 나타내는 것이 아니라 레코드가 그 사람의 특정한 상태를 나타낸다.

이러한 스타일의 프로그래밍에 도움을 주기 위해 with이라는 새로운 표현을 추가했다.

var otherPerson = person with { LastName = "Hanselman" };

With 표현은 객체의 이니셜라이저를 사용해 새 객체와 이전 객체 사이의 달라진 상태를 나타낸다. 한 번에 여러 속성을 정의할 수도 있다.

레코드는 암묵적으로 protected 속성의 "복사 생성자"를 정의한다. 이 생성자는 기존 레코드 객체를 입력받아 새 객체로 값을 복사한다.

protected Person(Person original) { /* copy all the fields */ } // generated

with 표현은 복사 생성자를 호출하고 객체 이니셜라이저를 그 위에 작용해 일부 값들을 변경한다.

만약 자동 생성된 복사 생성자의 동작이 마음에 들지 않는다면 직접 정의할 수도 있다.

값 기반 비교


모든 객체는 object 클래스로부터 Equals(object)라는 가상함수를 상속받는다. 이 함수는 Object.Equals(object, object) 라는 정적 함수를 호출할 때 만약 두 인자가 null이 아니라면, 그 값을 비교하기 위해 사용된다.

구조체나 레코드도 이 함수를 오버라이드(override)해 구조체의 각 값을 Equals를 재귀적으로 호출하며 "값 기반 비교"를 하게 된다.

즉 "값에 의해" 두 개의 서로 다른 레코드 객체가 같은 것으로 취급될 수 있게 된다. 예를 들어 변경된 person의 이름을 다시 돌려놓는다면,

var originalPerson = otherPerson with { LastName = "Hunter" };

이 둘이 같은 객체는 아니기 때문에 ReferenceEquals(person, otherPerson) = false가 되겠지만 그 값은 같기 때문에 Equals(person, originalPerson = true가 된다.

만약 자동으로 생성된 Equals의 값 기반 비교 동작이 마음에 들지 않는다면 직접 작성할 수도 있다. 값 기반 비교가 레코드에서, 특히 상속이 포함되어 있을 경우 어떻게 동작하는지만 잘 이해하고 있으면 된다. 이에 관한 자세한 내용은 아래에서 알아본다.

값 기반의 Equals 외에도 또 다른 값 기반 비교 함수인 GetHashCode()도 오버라이드해 사용할 수 있다.

Data의 멤버변수

레코드는 초기화 시점에만 그 멤버 변수들에 값을 할당할 수 있고 with 표현을 사용해서 비파괴적으로만 변경할 수 있다. 일반적인 경우 string FirstName으로 간단히 작성할 수 있다. 다른 클래스 및 구조체와 마찬가지로 명시적으로 private으로 선헌하지 않는다면 레코드에서도 멤버 변수들은 public이 된다.

public data class Person { string FirstName; string LastName; }

따라서 이러한 정의는 아래와 정확히 동일한 의미를 가진다.

public data class Person
{
    public string FirstName { get; init; }
    public string LastName { get; init; }
}

우리는 이러한 방법으로 아름답고 명확하게 레코드를 정의할 수 있다. 만약 정말 private 필드를 원한다면 명시적으로 private을 써주면 된다.

위치 기반

레코드의 멤버 변수에 위치에 기반해 접근하는 것이 유용한 경우가 있다. 생성자로 값을 할당하고, 위치 기반 소멸자로 그 내용을 가져올 수 있다.

다음처럼 직접 생성자와 소멸자를 레코드에 정의하는 것은 완벽히 올바른 방법이다.

public data class Person 
{ 
    string FirstName; 
    string LastName; 
    public Person(string firstName, string lastName) 
      => (FirstName, LastName) = (firstName, lastName);
    public void Deconstruct(out string firstName, out string lastName) 
      => (firstName, lastName) = (FirstName, LastName);
}

하지만 훨씬 짧게 정확히 같은 일을 할 수도 있다.

public data class Person(string FirstName, string LastName);

이렇게 정의하면 public인 초기화 전용 속성들과 생성자, 소멸자를 자동으로 생성할 수 있다. 따라서 다음과 같이 사용할 수 있게 된다.

var person = new Person("Scott", "Hunter"); // positional construction
var (f, l) = person;                        // positional deconstruction

레코드와 유동성

값 기반의 레코드는 유동적으로 변하는 상태와는 잘 어울리지 않는다. 레코드 객체 하나를 dictionary에 넣는다고 생각해보자. 그 값을 찾아오는 것은 Equals나 GetHashCode에 달려있는데 레코드가 그 상태를 바꾼다면 무엇과 같았다는 사실도 바뀌게 된다. 집어넣은 레코드를 다시 찾을 수 없게 될 수도 있다! 해시 테이블로 구현했을 경우에도 그 구조가 오염될 수 있다. 해시 테이블에서는 데이터가 "들어갔을 때"의 해시 코드를 기반으로 위치를 결정하기 때문이다.

캐싱 목적을 위해 레코드 안에서 유동적인 상태를 가지게 하는 등의 유용한 사용 방법이 있기는 하다. 하지만 상태 변화를 무시하고 레코드가 의도한대로 잘 동작하게 만들기 위해 레코드의 기본 동작을 수동으로 모두 오버라이드 해야하는 등의 번거로운 작업이 상당할 수 있다.

with 표현과 상속


값 기반의 동일성 체크와 비파괴적 변경은 상속이 끼어들었을 때 아주 복잡해진자. Student라는 피상속자 레코드 클래스를 추가해보자.

public data class Person { string FirstName; string LastName; }
public data class Student : Person { int ID; }

이제 실제로 Student를 생성해 Person 타입의 변수에 담아본다.

Person person = new Student { FirstName = "Scott", LastName = "Hunter", ID = GetNewId() };
otherPerson = person with { LastName = "Hanselman" };

마지막 줄의 with 표현에서 컴파일러는 person이 실제로 Student를 담고 있는지 알 수 있는 방법이 없다. 하지만 새로 만든 otherPerson이 실제로 Student 객체가 아니라면 복사가 제대로 이루어졌다고 볼 수 없다. ID 값은 복사되지 않았기 때문이다.

C#에서는 이러한 추론이 가능하다. 레코드는 전체 객체를 "복사"하는 역할의 숨겨진 가상함수를 가지고 있다. 부모에서 파생된 모든 레코드는 이 함수를 오버라이드해 해당 타입의 복사 생성자를 호출한다. 그리고 파생된 타입의 그 복사 생성자는 부모 레코드의 복사 생성자까지 타고 올라간다. 따라서 with 표현은 숨겨진 "clone" 함수를 호출하는것 만으로도 간단히 객체를 초기화 할 수 있게 된다.

값 기반의 동일성과 상속

with 표현을 사용할때와 마찬가지로 값 기반의 동일성을 검사할때도 "가상" 함수가 필요하다. Student는 정적으로 명시된 타입이 Person같은 부모 타입이어도 Student의 모든 값이 동일한지 비교해야 하기 때문이다. 이는 가상함수인 Equals를 오버라이드 하는 것으로 쉽게 구현할 수 있다.

하지만 여기에는 문제가 하나 있는데, 서로 다른 타입의 Person을 비교하면 어떻게 되는가이다. 둘 중 하나의 객체가 동일성 여부를 검사하게 둘 수는 없다. 동일성이란 서로 대칭이어야 하기 때문에 둘 중 어떤 객체가 먼저 오더라도 같은 결과가 나와야 한다. 달리 말하면 두 객체 모두 동의하는 경우에만 같다고 확인할 수 있다는 것이다.

이 문제를 보여주는 예제가 있다.

Person person1 = new Person { FirstName = "Scott", LastName = "Hunter" };
Person person2 = new Student { FirstName = "Scott", LastName = "Hunter", ID = GetNewId() };

이 두 객체는 서로 같을까? person2가 person1의 모든 요소를 가지고 있기 떄문에 person1은 그렇게 생각할 수도 있겠으나 person2는 다르게 생각할 것이다! 우리는 양쪽 모두가 서로 다른 객체라고 판단할 수 있게 만들어야 한다.

같은 말을 또 하지만, C#은 이 문제를 자동으로 해결해준다. 그 방법은 레코드가 EqualityContract라는 가상의 protected 속성을 가지고 있는 것이다. 모든 자식 레코드는 이 값을 오버라이드하고 같은지 비교할 때 사용한다. 두 객체는 반드시 같은 EqualityContract를 가지고 있어야 한다.

최상위 프로그램


C#에서는 간단한 프로그램을 작성하려고 해도 많은 양의 상용구를 필요로 한다.

using System;
class Program
{
    static void Main()
    {
        Console.WriteLine("Hello World!");
    }
}

이러한 요소는 언어를 처음 배우는 사람들을 압도할 뿐만 아니라 코드가 어지러워지고 들여쓰기 또한 늘어난다.

C# 9.0에서는 최상위 메인 프로그램을 다음과 같이 작성할 수 있다.

using System;
Console.WriteLine("Hello World!");

여기엔 어떤 내용도 올 수 있다. 단, using 다음에 와야 하고 해당 파일의 네임스페이스나 타입 선언 이전에 와야 한다. 기존 C#에서 Main 함수를 하나만 정의할 수 있던것 처럼 이러한 정의는 파일 한 개 에서만 할 수 있다.

만약 상태 코드를 반환하고 싶거나, 무언가 await하고 싶다면 그렇게 할 수 있다. 만약 커맨드 라인 인자에 접근하고 싶으면 args가 매직 파라미터로 존재한다.

로컬 함수들은 메인 프로그램의 일부로 최상위 프로그램 안에서 사용할 수 있다. 하지만 최상위 영역 밖에서 호출하는 것은 에러를 일으킨다.

향상된 패턴 매칭


C# 9.0에는 새로운 패턴이 추가되었다. 관련 문서에서 가져온 코드의 일부를 살펴보자.

public static decimal CalculateToll(object vehicle) =>
    vehicle switch
    {
       ...
       
        DeliveryTruck t when t.GrossWeightClass > 5000 => 10.00m + 5.00m,
        DeliveryTruck t when t.GrossWeightClass < 3000 => 10.00m - 2.00m,
        DeliveryTruck _ => 10.00m,
_ => throw new ArgumentException("Not a known vehicle type", nameof(vehicle))
    };

간단한 타입 패턴

현재는 타입 패턴을 사용할 때 위 에제의 DeliveryTruck _ 처럼 그 구분자가 _라고 하더라도 정의할 필요가 있었다. 하지만 이젠 이렇게 사용할 수 있다.

DeliveryTruck => 10.00m,

관계 패턴

c# 9.0에는 관계 연산자에 해당하는 패턴이 추가됐다. 따라서 위 에제의 DeliveryTruck 부분은 이렇게 switch를 사용해 표현할 수 있다.

DeliveryTruck t when t.GrossWeightClass switch
{
    > 5000 => 10.00m + 5.00m,
    < 3000 => 10.00m - 2.00m,
    _ => 10.00m,
},

여기에서 >5000과 <3000은 관계 패턴이다.

로직 패턴

마지막으로, 다른 표현들과 헷갈리는 것을 피하기 위해 and or not 같은 문자로 된 로직 패턴을 다른 연산자들과 함께 사용할 수 있다. 예를 들어 위 에제의 스위치문의 case을 다음처럼 오름차순으로 작성할 수 있다.

DeliveryTruck t when t.GrossWeightClass switch
{
    < 3000 => 10.00m - 2.00m,
    >= 3000 and <= 5000 => 10.00m,
    > 5000 => 10.00m + 5.00m,
},

and를 사용하는 중간의 case는 두 관계 패턴을 합쳐서 중간값을 나타낸다.

not 패턴은 일반적으로 null 상수 패턴과 함께 not null의 형태로 사용된다. 예를 들어 unknown case를 null인지 여부에 따라 분리할 수 있다.

not null => throw new ArgumentException($"Not a known vehicle type: {vehicle}", nameof(vehicle)),
null => throw new ArgumentNullException(nameof(vehicle))

또한 not은 is 표현을 포함하는 if 조건문에서 사용하기에 편리하다.

if (!(e is Customer)) { ... }

이렇게 괄호를 중첩해서 표현하는 대신 다음처럼 간단하게 정의할 수 있다.

if (e is not Customer) { ... }

향상된 타입 추론


타입 추론(Target Typing)은 표현에서 사용되는 타입을 문맥에서 유추하는 것을 의미한다. 예를 들어 null과 람다 표현은 항상 타입 추론을 사용한다.

C# 9.0에서는 이전에 불가능하던 몇 가지 요소를 추론할 수 있다.

new 표현 추론

C#에서 new는 기존에 항상 타입을 명확히 명시해야 했다(암시적으로 추론되는 배열 형태 제외). 이제는 만약 값을 할당하는 대상 변수의 타입이 명확하다면 new의 타입을 생략할 수 있다.

Point p = new (3, 5);

?: ??추론

가끔 ??과 ?: 표현의 요소들이 공유하는 타입이 명확하지 않을 때가 있다. 다음과 같은 표현은 지금은 불가능하지만, C# 9.0에서는 두 요소들이 공유하는 타입이 있다면 사용 가능하다.

Person person = student ?? customer; // Shared base type
int? result = b ? 0 : null; // nullable value type

반환 타입 변환

파생된 클래스의 오버라이드 함수에서 부모 클래스에 정의된 것보다 더 특정한 타입을 반환하고 싶을 수 있다. C# 9.0에서는 다음과 같이 사용할 수 있다.

abstract class Animal
{
    public abstract Food GetFood();
    ...
}
class Tiger : Animal
{
    public override Meat GetFood() => ...;
}

더 많은 정보들..


C# 9.0에 기능 전체와 그 진행 정도를 확인하기 위한 가장 좋은 장소는 Roslyn (C#/VB 컴파일러) 깃허브 저장소이다.

해피 해킹!

profile
개발새발

0개의 댓글