Rust Programming | 제약조건에 대해

제약조건에 대해 알아보자

Author: Han Damin
Tags: rust study generic
Published: 2024/12/05 06:20 Series: Rust

러스트에서 제네릭(Generic)은 매우 강력한 기능으로, 코드의 재사용성을 극대화하면서도 컴파일 타임에 타입 안정성을 보장한다. 하지만 제네릭을 사용하는 과정에서 특정 타입이 어떠한 조건을 만족해야 할 때 이를 명시해야하는데, 러스트에서는 이를 제약조건(Constraints)이라고 부르며, where 절을 사용해 표현한다.



제약 조건이란?

제약조건은 제네릭 타입에 추가적인 트레이트 바운드(Trait Bound)를 적용하여 해당 타입이 특정한 동작(메서드, 연산 등)을 수행할 수 있도록 보장한다. 러스트의 강력한 타입 시스템의 핵심으로, 타입 안정성을 제공하면서도 유연한 프로그래밍을 가능하게 하는게 특징이다.


기본적인 사용법

제약조건은 함수, 구조체, 열거형, 트레이트 등 다양한 곳에서 사용되며, 아래의 두 가지 방식으로 표현한다.

a. 인라인

제약조건을 함수 꺽쇄안에 바로 명시하는 방법으로, 아래와 같이 사용한다.

fn example<T: Display>(value: T) {
    println!("{}", value);
}
  • 여기서 T: Display는 제네릭 타입 T가 Display 트레이트를 구현해야 함을 나타낸다.
  • 이 방식은 간단한 경우에 적합하지만, 복잡한 제약조건이 많아지면 가독성이 떨어질 수 있다는 단점이 있다.

b. where clause

복잡한 조건을 명시할때 좋은 방법으로, 아래와 같이 사용한다.

fn example<T, U>(x: T, y: U)
where
    T: Display + Clone,
    U: Debug,
{
    println!("x: {}, y: {:?}", x, y);
}
  • T: Display + Clone: T는 Display와 Clone 트레이트를 구현해야 한다.
  • U: Debug: U는 Debug 트레이트를 구현해야 한다.

사용 예시

pub struct DispatchKey {
    device_type: DeviceType,
    device_index: usize,
}

impl DispatchKey {
    pub fn new(device_type: DeviceType, device_index: usize) -> Self {
        Self {
            device_type,
            device_index,
        }
    }

    pub fn dispatch<F, R>(&self, cpu_fn: F, cuda_fn: F) -> R
    where
        F: FnOnce() -> R,
    {
        match self.device_type {
            DeviceType::CPU => cpu_fn(),
            DeviceType::CUDA => cuda_fn(),
        }
    }
}

위 코드는, 필자의 ML 프레임워크 중 일부를 따온것이다.

우선 FR에 대해 알아보자, dispatch 함수에서 F는 클로저 타입이고 R은 클로저가 반환할 타입이다. where clause에 따르면, cpu_fn() 또는 cuda_fn() 호출 결과로 같은 타입의 값(R)이 반환됨이 명시 되어져있다.


dispatch 함수에서 중요한 제약조건은 아래와 같다.

where
    F: FnOnce() -> R,

이 제약조건을 하나씩 풀어서 보면 다음과 같다.

  • F: 제네릭 타입이며, 여기서는 클로저 타입으로 사용된다. dispatch 메서드는 이 F를 사용하여 작업을 수행한다.
  • FnOnce() -> R: F는 반드시 FnOnce 트레이트를 구현해야 한다. 즉, F는 실행 가능한 클로저여야 하며, 호출되면 반환 타입 R의 값을 반환해야 한다.

FnOnce는 러스트에서 클로저를 나타내는 세 가지 주요 트레이트 중 하나로, 클로저가 실행 가능한지 여부를 컴파일 타임에 보장한다. FnOnce는 클로저가 소유권을 가져가야 하는 상황에서도 작동할 수 있다.

FnOnce의 특성:

클로저를 한 번만 실행할 수 있다. 소유권을 사용하는 작업이 허용된다.

위 코드에서는 CPU와 CUDA에서 각각 FnOnce 클로저를 전달받아 실행한다. 덕분에 각 클로저는 필요한 데이터를 자유롭게 소비할 수 있으며, 실행 후 반환 값이 R로 설정된다.


만약 제약조건을 생략하거나 잘못 설정했다면 어떤 일이 발생할까?

pub fn dispatch<F, R>(&self, cpu_fn: F, cuda_fn: F) -> R {
    match self.device_type {
        DeviceType::CPU => cpu_fn(), // 오류 발생 가능
        DeviceType::CUDA => cuda_fn(), // 오류 발생 가능
    }
}

위처럼 제약조건이 없을 경우:

  • 클로저가 아닌 일반 타입이 잘못 전달될 가능성이 있다.
  • CPU와 CUDA 작업의 반환 타입이 일치하지 않아 런타임 에러를 유발할 수 있다.
  • 컴파일러가 클로저 호출 가능 여부를 확인할 수 없으므로 안정성을 보장할 수 없다.

이를 보완하기 위해 F: FnOnce() -> R 라는 제약조건을 설정한 것이다.


정리

제약조건은 코드의 안전성과 유연성을 동시에 제공하는 매우 중요한 기능이다. dispatch 메서드의 where F: FnOnce() -> R 제약조건은 다음과 같은 장점을 제공한다:

  1. 타입 안전성: cpu_fn과 cuda_fn이 반드시 호출 가능한 클로저임을 보장한다.
  2. 코드 유연성: 클로저 내부에서 소유권을 사용하는 작업도 가능하다.
  3. 일관된 인터페이스: 반환값의 타입이 반드시 일치하도록 강제한다.

러스트에서 제약조건은 특히 제네릭 프로그래밍에서 필수적인 도구이다. 이를 적절히 활용하면 타입 안정성을 유지하면서도 재사용 가능한 코드를 작성할 수 있다.