이번에는 트레잇을 사용하며 겪을 수 있는 문제를 중심으로 이야기 해보자. 그 전에 트레잇 예시를 보자.
I am i8 I am f32
Hello 트레잇을 가진 타입은 hello 메서드를 가지고 있게 된다. 즉 어떤 타입에 Hello 트레잇을 부여하려면 정해진 시그니처의 hello 메서드를 구현해 줘야 한다. 만약 정체를 모르는 타입이 Hello 트레잇을 가지고 있다면, 이 타입의 크기가 무엇인지도 알 수 없고 오직 hello 메서드만큼은 호출할 수 있다는 것을 안다. 그래서 이 트레잇을 가진 타입을 인자로 받는 함수를 만들 수 있다.
I am i8 and I am i8
h, g의 타입이 impl trait으로 돼있는데 이 뜻은 이 함수가 호출될 때마다 컴파일 시간에 인자의 타입을 추론하라는 뜻이다. 즉 제너릭은 아니지만 제너릭처럼 컴파일러가 다른 코드를 만들어낸다고 보면 된다. 함수를 호출할때마다 타입을 추론해서 컴파일러가 코드를 다르게 만들어야 하는 이유는 Hello 트레잇을 가졌다고 하더라도 i8이냐 f32냐에 따라 크기가 1바이트, 4바이트이기 때문에 인자를 받는 과정도 달라야 하고, 호출하는 메서드 함수의 주소도 다르기 때문이다.
비슷하게 제너릭을 아래처럼 사용해도 컴파일 시간에 타입을 추론해준다.
제너릭을 사용하는 방식과 impl trait을 사용하는 방식은 완전히 같지 않다. 차이는 좀 있다가 알려줄게.
한편 런타임에 타입을 추론하도록 하려면 아래처럼 고정된 크기를 갖는 참조자 &dyn trait을 전달하면 된다.
이제 문제를 보자.
클로저 문제
호출 할때마다 1씩 증가하는 숫자를 출력하는 클로저를 만들면 아래와 같다.
클로저는 단순한 함수 포인터가 아니다. 클로저는 캡처한 참조자를 가지거나 아예 소유권을 가지는 내부 상태가 있다. 즉 단순히 주소 값인 함수포인터와 다르게 클로저는 크기가 다를 수 있는 타입이고 타입이름은 알 수 없다. 한편 클로저를 반환하는 함수는 다음과 같이 만든다.
make 함수의 반환형을 보자. FnMut()을 풀어 쓴 FnMut() -> ()은 트레잇이고 impl trait 방식으로 반환하고 있다. 이렇게 하는 이유는 클로저의 타입이름을 알 수 없어서 명시적으로 쓸 수 없기 때문이다. 이 경우 컴파일러가 알아서 타입이름 문제를 해결해준다. 하지만 아래 코드를 보자.
error[E0308]: `if` and `else` have incompatible types
--> src/main.rs:5:9
|
2 | / if cnt > 0 {
3 | | move || println!("{}", (cnt, cnt+=1).0)
| | --------------------------------------- expected because of this
4 | | } else {
5 | | move || println!("{}", (cnt, cnt-=1).0)
| | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected closure, found a different closure
6 | | }
| |_____- `if` and `else` have incompatible types
|
= note: expected type `[closure@src/main.rs:3:9: 3:48]`
found closure `[closure@src/main.rs:5:9: 5:48]`
= note: no two closures, even if identical, have the same type
= help: consider boxing your closure and/or using it as a trait object
두 클로저의 타입이 다르다고 한다. 클로저의 타입은 타입이름도 없고 심지어 절대 서로 같을 수가 없다. 이런 문제는 어떻게 해결할까? 가장 쉬운 방법은 Box<dyn trait>으로 묶는 방법이다.
박스(Box)에 담는 이유는 클로저의 크기가 다를 수 있기 때문이고, dyn trait을 사용해 런타임 추론을 해서 클로저를 호출한다.
동적 할당 대신에 다른 방법은 없을까? 둘을 enum으로 묶어 한 타입으로 만드는 방법이 있다.
위 방법을 통하면 런타임에 A인지 B인지 분석을 해서 원하는 함수를 호출시켜준다. 위에서는 make 함수를 impl trait으로 구현했지만 만약 이것을 제너릭으로 구현하면 타입추론에 실패한다.
왜냐하면 제너릭은 입력값과 출력값이 함수 외부에서 어떻게 사용되는지로 타입을 추론하지, 내부에서 어떻게 사용되는지로 추론하지 않는다. 위 경우는 반환하는 클로저의 타입을 밖에서는 모르고 함수 안에서만 클로저의 타입을 아는 경우이므로 impl trait으로 해야한다. 게다가 모든 클로저는 타입이 다르므로 외부에서 어떻게 사용되는지로는 절대 추론할 수가 없다. 이처럼 트레잇 관련 문제를 제너릭 either enum을 통해 해결하는 방법은 자주 쓰이니 알자
한편 이 impl trait 구문은 그냥 메서드에서는 되지만 트레잇의 메서드에서는 사용이 불가능하다.
위에 거는 되고
error[E0562]: `impl Trait` not allowed outside of function and method return types
--> src/main.rs:3:30
|
3 | fn make(cnt: i32) -> impl FnMut();
| ^^^^^^^^^^^^
error[E0562]: `impl Trait` not allowed outside of function and method return types
--> src/main.rs:6:30
|
6 | fn make(mut cnt: i32) -> impl FnMut() {
|
이건 안된다. 이때는 해결책을 찾지말고 편하게 Box<dyn trait>을 쓰자.
오지랖 많은 트레잇 문제
아래 재밌는 코드를 보자
std::fmt::Display 트레잇을 가진 타입은 자동으로 Foo의 Hello할 수 있도록 하고, 그외에 Display 트레잇이 없는 Foo도 자기 자신하고도 Hello trait을 구현했다. Foo는 std::fmt::Display 트레잇이 없기 때문에 중복 구현이 아니고 문제없이 컴파일이 된다. 그러나 아래 코드는 에러가 난다.
에러의 내용은 (Foo, Foo)가 std::fmt::Display 트레잇을 미래에 가질 수 있기 때문에 중복 구현 에러라는 것이다. 근데 지금은 (Foo, Foo)가 std::fmt::Display를 가지지 않는다. 언제 그렇게 될지도 기약이 없다. 왜 Foo는 std::fmt::Display 가능성이 없고 (Foo, Foo)는 있을까? (Foo, Foo)도 결국은 외부 타입인 튜플이므로 언젠간 러스트가 업데이트 된 후 모든 튜플이 std::fmt::Display를 가질 수도 있기 때문이다. 근데 그럴 일은 평생 없으니 이 에러에 고마워 할 필요는 없다. 해결책을 찾아보자. 가장 쉬운 방법은 std::fmt::Display 트레잇을 가진 타입이 많다고 해서 제너릭으로 만들지 말고 일일이 나열하는 것이다. Foo의 친구를 하나씩 추가해주자.
나열이 힘들면 절차 매크로를 사용해서 일일이 나열하면 된다. 새로운 트레잇을 만들고 그 트레잇을 일일이 부여하는 것도 좋다.
이밖에 다양한 트레잇 관련 문제로 골치가 아파질 수 있다. 이럴 때를 위한 이야기가 있다. 알렉산드로스 대왕은 풀리지 않는 고르디우스의 매듭을 단칼에 잘랐다고 알려진다. 다음 글에서는 러스트의 특성인 트레잇을 복잡하게 활용하여 파이썬의 슬라이싱을 흉내낼 것이다.
댓글 0