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
속성인 값들을 수정하는 것이 가능하다.
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)));
}
}
초기화 전용 속성은 개별 속성을 수정 불가능(immutable)하게 만드는데 유용하다. 만약 객체 전체가 수정 불가능해 값(value)처럼 동작하기를 원한다면 객체를 Record로 선언하는 것을 고려해볼 수 있다.
public data class Person
{
public string FirstName { get; init; }
public string LastName { get; init; }
}
클래스의 data
키워드는 클래스를 레코드로 만든다. 이렇게 정의된 클래스는 값과 비슷한 몇 가지 특징을 가지게 된다. 일반적으로 레코드는 객체보단 값(데이터)
으로 보여야 한다. 내부적으로 수정 가능한 상태를 가지고 있지 않아야 하며, 새로운 상태를 표현하기 위해 새 레코드를 생성한다. 데이터는 그 내용 자체로 하나의 고유한 값이 된다.
변경이 불가능한 데이터로 작업을 할 때 새로운 상태를 표현하기 위한 일반적인 방법은 기존의 값을 이용해 새 값을 만드는 것이다. 예를 들어 예제 속 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()
도 오버라이드해 사용할 수 있다.
레코드는 초기화 시점에만 그 멤버 변수들에 값을 할당할 수 있고 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
에 달려있는데 레코드가 그 상태를 바꾼다면 무엇과 같았다는 사실도 바뀌게 된다. 집어넣은 레코드를 다시 찾을 수 없게 될 수도 있다! 해시 테이블로 구현했을 경우에도 그 구조가 오염될 수 있다. 해시 테이블에서는 데이터가 "들어갔을 때"의 해시 코드를 기반으로 위치를 결정하기 때문이다.
캐싱 목적을 위해 레코드 안에서 유동적인 상태를 가지게 하는 등의 유용한 사용 방법이 있기는 하다. 하지만 상태 변화를 무시하고 레코드가 의도한대로 잘 동작하게 만들기 위해 레코드의 기본 동작을 수동으로 모두 오버라이드 해야하는 등의 번거로운 작업이 상당할 수 있다.
값 기반의 동일성 체크와 비파괴적 변경은 상속이 끼어들었을 때 아주 복잡해진자. 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 컴파일러) 깃허브 저장소이다.
해피 해킹!