함수형 프로그래밍을 하다보면 ADT라는 말을 간간히 들을 수 있다. Algebraic Data Type의 준말로, 한국어로는 '대수적 자료형'이라고 한다. ADT는 여러 타입의 조합으로 만들어 질 수 있는 타입이라곤 하는데, 이 글에서는 'ADT가 무엇이냐?'에 관심을 가지는 것이 아닌, 'ADT를 어떻게 실용적으로 사용할 수 있는가?'가 관심사이므로 ADT에 대해 논하는 것은 크게 하지 않도록 하겠다. 아무튼, ADT에 대표적인 예시로 합 타입과 곱 타입이 있다.
A x B x C로 쓸 수 있는 것을 Rust, Python에서는 다음과 같이 나타낼 수 있다.Rust: struct ProductType(A, B, C)
Python: ProductType = tuple[A, B, C]
A | B | C로 쓸 수 있는 것을 Rust, Python에서는 다음과 같이 나타낼 수 있다.Rust
struct A;
struct B;
struct C;
enum SumType {
TypeA(A),
TypeB(B),
TypeC(C)
}
Python
class A:
pass
class B:
pass
class C:
pass
class SumType(Enum):
TypeA = A
TypeB = B
TypeC = C
이 둘의 타입을 엮어서 다양한 자료를 쉽게 엮어서 표현할 수 있다. 이런 내용들은 많은 곳에서 쉽게 다루고 있으며, 많이 사용하고 있을 것이다. 하지만 실제로 사용해볼 때는 저정도의 단순한 ADT는 많이 아쉽다. 개인적으로 sealed trait(class)을 붙여서 타입을 확장하는 방식의 ADT가 훨씬 실용적이고 풍부한 기능성을 지닌다고 생각한다. 이번 글에서는 이것을 소개해보고, Rust에서 어떻게 도입되면 좋을 지와, 도입되지 않은 현재 상황에서 최대한 비슷하게 구현해볼 수 있는 방향성을 이야기해보려고 한다. 이는 PEP 636에도 담겨있는 내용과 비슷하다. 다만, Python은 힌팅된 타입에 의존하는 방향이기 때문에...무조건 신뢰할 수는 없다. pyright, mypy를 쓴다 하더라도 말이다.(애초에 라이브러리들 중에서 타입을 잘 적어둔 경우가 생각보다 많지 않다.)
내가 이러한 생각을 하게 된 계기는 Scala의 ADT를 사용해본 경험이다. 가령, 다음과 같이 enum을 정의해볼 수 있다.(velog에 Scala highlight 추가해주세요 ㅠㅠ)
sealed trait A
sealed trait B extends A
object A {
case object Alpha extends A
case object Beta extends B
case object Gamma extends A
case object Delta extends B
case object Epsilon extends A
}
sealed trait은 한 소스파일(파일 단위)에서만 상속할 수 있도록 강제하는 interface의 일종이다. 위와 같이 enum을 정의하면 A에 묶인 원소들은 Alpha, Beta, Gamma, Delta, Epsilon이며, B에 묶인 원소들은 Beta, Delta가 된다. 기존에 enum을 정의할 때 enum 안에 있는 원소를 동시에 다른 enum에서 갖다 쓸 수 없었지만, 이렇게 trait을 상속하는 방법으로 enum을 정의하면 부분집합 enum을 정의할 수도 있고, 서로 겹치는 원소가 있는 enum을 만들 수도 있다. 실제로 도메인에 따라 entity를 정의하면, 계층식으로 enum을 정의하여 활용하는 것이 효율적일 때가 있는데 그때 정말 편리하다. 게다가 타입 안전성도 확보가 되므로(B에 속한 애들은 당연히 A에도 속하므로, B 타입을 A 타입에 대입시켜도 문제가 없음이 보장된다) 불필요한 if문 체크 등을 하지 않고 쉽게 사용할 수 있다. 당연히 exhaustive pattern matching도 된다.
그렇다면 현행 Rust는 어떤 구조를 가지고 있길래 이런 방향으로 개선이 필요하다고 말하는 것일까? 다음의 예시를 살펴보면 쉽게 이해할 수 있다. 가령, 여러 구조체들을 하나의 합 타입으로 나타내어 사용하려면 상당히 귀찮다.
trait Animal {
fn name() -> String;
}
struct Dog {
properties: Vec<String>,
walk: Cell<WalkProperty>,
}
impl Animal for Dog {
fn name() -> String {
"Dog".to_string()
}
}
struct Cat {
properties: Vec<String>
}
impl Animal for Cat {
fn name() -> String {
"Cat".to_string()
}
}
이런 경우에 Box<dyn Animal>에 대해서 name()을 호출하여 동물의 이름을 얻어올 수는 있지만, 그 객체의 내부 state를 변경하거나 각 case별 처리를 할 수는 없다. 물론, trait에 fn mut_properties(&mut) -> &mut Vec<String> 같은 걸 넣어서 일반화할 수도 있지만, 정말 case별 처리를 하는 경우에는 절대 일반화할 수 없는 경우가 있다. 가령, 개는 산책을 나가는데 고양이는 산책을 나가지 않는다. 이때 산책을 나갈 때의 무언가를 처리할 수 있는 로직을 일반화하는 것은 말이 안 되지 않는가? 억지로 일반화하고, 그것에 해당하지 않는 구조체에 대해서는 unreachable!() 같은 것을 넣어놔서 어떻게 할 수는 있지만, 이는 컴파일 시간에 안전하지 않다.
따라서 현행 Rust에서는 이런 것을 하려면 downcast를 하여서 처리를 해야 한다. 여러 방법이 있는 듯한데, std::any::Any의 downcast와 downcast-rs를 활용하는 방법이 있는 듯하다. 둘 다 아래 슈도코드와 비슷한 느낌으로 사용할 수 있다.
let animal: Box<dyn Animal>;
if let Some(dog) = animal.downcast_ref::<Dog>() {
... // dog logic
} else if let Some(cat) = animal.downcast::<Cat>() {
... // cat logic
}
정말 귀찮은 코드 아닌가? 심지어 이는 exhaustive하지도 않을 뿐더러, 컴파일 타임에 체크되는지 알 수가 없다. 아마 매크로가 아니기 때문에 안 될 것이다. 정말 안전하지 않은 코드이다. 이게 만약에 Scala 같은 언어였으면 아래와 같이 쉽게 할 수 있다.
val animal: Animal
animal match {
case dog: Dog => ...
case cat: Cat => ...
}
그나마 Rust의 enum이 exhaustive pattern matching이 된다는 점을 활용하면, boilerplate 코드가 있지만 아래와 같이 구현할 수는 있다.
enum AnimalEnum {
Dog(Dog)
Cat(Cat)
}
let animal: AnimalEnum;
match animal {
AnimalEnum::Dog(dog) => ...
AnimalEnum::Cat(cat) => ...
}
enum을 일일히 정의해야 하고, match할 때도 wrap enum을 한꺼풀 제거해야 하는 것이 상당히 귀찮다. 심지어 이것은 enum에 대해서 컴파일시 안전할 뿐이지, enum에 실수로 trait을 상속하고 있는 것을 넣지 않으면 빼먹고 코드를 짤 수 있어서 human error가 날 여지가 다분히 있다. 참고로 Box<dyn Animal>은 dynamic dispatch를 활용하지만, 이런 방식으로 하면 static dispatch이기 때문에 method를 실행하는 속도도 빨라진다. enum_dispatch를 살펴보면 위와 같은 코드를 좀 더 퍼포먼스 면에서도 이점을 누리고 boilerplate 코드를 줄일 수 있어서 조금은 편하다.
https://internals.rust-lang.org/t/pre-rfc-sealed-traits/3108/37에서 논의가 이뤄진 적이 있는 듯하다. 다만, 이게 언어적으로 직접 반영이 되려면 Sized인 경우를 고려해야 하고 등등 고려해야 할 점이 한두 가지가 아니라서 조용히 close되버린 것 같다. 정말 아쉬운 부분이다.
위와 같은 코드의 boilerplate를 제거하여 매크로로 생성하면 어떨까? 요즘 코드 짤 시간이 없어서 구현은 아직 못 해봤지만 필자의 아이디어를 소개해보면 다음과 같은 방향이 있지 않을까 싶다.
#[sealed]
trait Animal {}
struct Dog;
impl Animal for Dog {}
struct Cat;
impl Animal for Cat {}
다음과 같은 코드가 있을 때, Scala의 sealed trait처럼 같은 소스파일에 대해서 매크로를 통해서 다음과 같이 생성해줄 수 있을 것이라 기대한다.
trait SealedAnimal: Sealed {}
struct Dog;
impl SealedAnimal for Dog {}
struct Cat;
impl SealedAnimal for Cat {}
enum Animal {
Dog(Dog),
Cat(Cat),
}
mod private {
pub trait Sealed {}
}
이로써 다음과 같이 exhaustive pattern matching이 가능해진다.
let animal: Animal;
match animal {
Dog(dog) => ...
Cat(cat) => ...
}
enum 이름만 어떻게 좀 더 최적화할 수 있으면 좋을 거 같긴 한데, 그쪽 아이디어는 딱히 떠오르는 게 없긴 했다.
아주 간단한 아이디어라서 실제로 매크로 등으로 구현하고 사용할 때 어떻게 잘 될지는 잘 모르겠지만, Scala의 sealed trait을 Rust에서 사용할 수 있으면 더할 나위 없는 바람일 거 같다.
Rust의 ADT는 실용적이긴 하지만, 좀 더 좋은 생산성과 컴파일 타임의 안전성을 갖춘 trait downcasting 등을 쓰려면 Scala 같은 sealed trait이 추가되면 좋을 것이다. 아마도 언어의 특징상 들어가기 보다는 macro로 따로 관리하는 방향이 좋을 거 같고, 언젠가 시간이 되면 꼭 만들어서 실서버에서도 도입해보고 싶다.