러스트에서에서는 함수 오버로딩이 안된다. 그럼에도 해야할 때가 있다. 다음엔 연산자 오버로딩을 통해 파이썬과 같은 문자열 슬라이스를 제공하는 쓸만한 기능을 구현해볼거다. 이것이 무엇인지, 왜 필요한지, 가장 좋은 방법이 무엇인지 고민해보자.
파이썬에는 range라는 함수가 있다.
[_ for _ in range(5)] # [0, 1, 2, 3, 4][_ for _ in range(3, 5)] # [3, 4]
[_ for _ in range(5, 0, -1)] # [5, 4, 3, 2, 1]
이걸 러스트에서도 비슷하게 만든다면 함수 시그니쳐는 아래처럼 생겨야 한다.
fn range(start=0: isize, stop: isize, step=1: isize) -> impl Iterator<Item=isize> { // TODO
}
안된다. 러스트에서는 start=0, step=1처럼 기본 인자값(default argument)를 지원하지 않는다. 그렇다면 다음 방식은 어떨까?
fn range(stop: isize) -> impl Iterator<Item=isize> { range(0, stop, 1)
}
fn range(start: isize, stop: isize) -> impl Iterator<Item=isize> {
range(start, stop, 1)
}
fn range(start: isize, stop: isize, step: isize) -> impl Iterator<Item=isize> {
// TODO
}
안된다. 러스트에서는 인자의 개수와 상관 없이 같은 이름의 함수를 만들 수 없다. 그럼 오버로딩을 어떻게 구현할까?
처음 생각해볼 수 있는 것은 러스트가 강요하는 방법이다. 러스트는 이런 경우 3가지 함수의 이름을 다르게 하여 구현할 것을 바란다.
fn range_stop(isize) -> impl Iterator<Item=isize>;fn range_start_stop(isize, isize) -> impl Iterator<Item=isize>;
fn range_start_stop_step(isize, isize, isize) -> impl Iterator<Item=isize>;
위 처럼 한다면 문제는 해결된다. 하지만 이건 오버로딩이 아니고 보기 역겹다. 미를 추구하는 많은 러스트 선구자들(pioneers)은 이 문제를 해결할 돌파구(breakthrough)를 찾으며 다양한 방법을 시도해 왔다. 한 방법은 매개변수를 Option<T>으로 받는 것이다.
fn main() { for i in range(Some(1), Some(10), None) {
println!("{i}");
}
}
fn range(start: Option<isize>, stop: isize, step: Option<isize>) -> impl Iterator<Item=isize> {
let start = start.unwrap_or(0);
let step = step.unwrap_or(1);
// TODO
}
위와 같은 방법도 좋지만 함수 호출할 때마다 매개변수를 Some 또는 None으로 채워야 한다는 것은 프로그래머 입장에서 상당히 피곤하다. 그래서 선구자들은 None은 인자 개수를 채우기 위해 명시적으로 쓰고, Some이라도 안쓰는 방법을 제너릭과 Into trait을 통해 구현했다.
fn main() { for i in range(1, 10, None) {
println!("{i}");
}
}
fn range<T: Into<Option<isize>>, U: Into<Option<isize>>>(start: T, stop: isize, step: U) -> impl Iterator<Item=isize> {
let start = <T as Into<Option<isize>>>::into(start).unwrap_or(0);
let step = <U as Into<Option<isize>>>::into(step).unwrap_or(1);
// TODO
}
다른 방법으로 매개변수가 담긴 구조체를 전달하는 방법이 있다. 구조체를 이용한 전달방식의 장점은 매개변수의 이름에 따라 명시적으로 값을 지정해줄 수 있는 장점이 있다. 하지만 가장 마음에 드는 방식은 튜플을 전달하는 방식이다. 이 방식과 From 트레잇을 사용하면 매개변수 개수와 타입을 마음대로 할 수 있다.
struct RangeArg(isize, isize, isize);
impl From<isize> for RangeArg {
fn from(arg: isize) -> Self {
RangeArg(0, arg, 1)
}
}
impl From<(isize,)> for RangeArg {
fn from(arg: (isize,)) -> Self {
RangeArg(0, arg.0, 1)
}
}
impl From<(isize,isize)> for RangeArg {
fn from(arg: (isize,isize)) -> Self {
RangeArg(arg.0, arg.1, 1)
}
}
impl From<(isize,isize,isize)> for RangeArg {
fn from(arg: (isize,isize,isize)) -> Self {
RangeArg(arg.0, arg.1, arg.2)
}
}
fn range<T>(arg: T) -> impl Iterator<Item=isize> where RangeArg : From<T> {
let RangeArg(mut now @ start, stop, step) = arg.into();
std::iter::from_fn(move || {
if step >= 0 {
if now >= stop {
None
} else {
Some((now, now+=step).0)
}
} else {
if now <= stop {
None
} else {
Some((now, now+=step).0)
}
}
})
}
fn main() { println!("{:?}", range((5)).collect::<Vec<_>>());
println!("{:?}", range((3, 5)).collect::<Vec<_>>());
println!("{:?}", range((5, 0, -1)).collect::<Vec<_>>());
}
[0, 1, 2, 3, 4][3, 4]
[5, 4, 3, 2, 1]
괄호를 두 번만 감싸면 되서 사용이 편하다. 위 방식의 단점은 함수가 한 개라는 점이다. 그래서 한 개의 함수 안에 모든 것을 구현해야 한다. 그래서 다양한 종류의 인자를 받을수록 함수 한 개 안은 다양한 분기로 굉장히 복잡해진다. 그리고 인자타입이 다양할수록 enum을 사용해야 하면서 점점 복잡해지며 함수가 하나이기 때문에 인자에 따라 함수의 반환타입도 다양하게 만들기 어렵다. 다른 방법이 있다. 트레잇의 제너릭을 사용하면 같은 이름의 여러 함수를 구현할 수 있다. 아래는 이 방법을 사용해서 이름이 같은 다양한 add 함수를 만든 것이다.
struct AddStruct; // 껍데기
trait AddImpl<T> {
type Output;
fn add(arg: T) -> Self::Output;
}
impl AddImpl<(f32,f32)> for AddStruct {
type Output = f32;
fn add(arg: (f32,f32)) -> Self::Output {
arg.0 + arg.1
}
}
impl AddImpl<(i32,i32)> for AddStruct {
type Output = i32;
fn add(arg: (i32,i32)) -> Self::Output {
arg.0 + arg.1
}
}
impl AddImpl<(i32,f32,f32)> for AddStruct {
type Output = f32;
fn add(arg: (i32,f32,f32)) -> Self::Output {
arg.0 as f32 + arg.1 + arg.2
}
}
fn add_shortcut<T>(arg: T) -> <AddStruct as AddImpl<T>>::Output
where AddStruct : AddImpl<T> {
<AddStruct as AddImpl<T>>::add(arg)
}
fn main() { println!("{}", AddStruct::add((2, 3)) );
println!("{:.2}", add_shortcut((1.0, 1.0)) );
println!("{:.2}", add_shortcut((1, 1.0, 1.0)) );
}
위 방법은 익숙하다. 표준 라이브러리의 연산자 오버로딩은 위 방법과 비슷하게 트레잇 제너릭을 통해 구현되어 있다. 덕분에 같은 이름의 함수에 다양한 타입을 넣을 수 있다. 위 방법의 단점은 트레잇 메서드는 제약이 많아 반환타입으로 impl trait을 사용할 수 없다는 점이다. 그래서 impl Iterator<Item=isize>를 반환하는 트레잇 메서드를 구현할 수 없다. 그렇다고 Box<dyn trait>를 사용하기에는 오버헤드가 있다. 이 문제는 nightly버전의 #![feature(type_alias_impl_trait)]로 해결이 가능하다고 알려져 있다.
그럼 이 오버로딩이 왜 필요할까? 위 방법을 통하지 않고 절차 매크로(procedural macro)를 통해서도 함수형 매크로를 통해 오버로딩 함수를 만들 수 있다. 하지만 인자 타입을 구별하고 다른 반환타입 만들어내는 것은 macro_rules!으로 만든 매크로 함수로는 힘들어, 토큰 분석기를 사용해야 한다. 이는 위에서 설명한 방법보다 더 복잡하다. 그리고 매크로 함수는 메서드로 사용이 불가능하며, 연산자 오버로딩에도 적용이 불가능한 방법이다. 다음 글에서 위에서 설명된 방법을 통해 파이썬의 문자열 슬라이스를 구현할 것이다.
오버로딩이랑 default argument 지원 안하는거 진짜 별로야..
이런 글 좋아
그냥 매크로쓰자