Rust에는 클래스 상속이 없지만, trait, impl, 제약, 매크로, 합성을 통해 공유 인터페이스와 공유 동작을 구현할 수 있다. 이 글은 아홉 가지 작은 퍼즐을 통해 Rust에서 상속과 비슷한 효과를 내는 기법들을 설명한다.
17분 읽기
2026년 6월 1일
--
엔터를 누르거나 클릭하여 이미지를 전체 크기로 보세요

Rust 게는 단순한 조각들로부터 작고 명시적인 구조를 쌓아 올리고, 그 뒤에는 클래스 상속 트리의 그림자가 드리워져 있다. Rust는 상속과 비슷한 여러 효과를 낼 수 있지만, 클래스 계층 대신 trait, impl, bounds, 매크로, 그리고 합성을 통해 그렇게 한다. 이미지는 ChatGPT로 생성했다.
이 글의 영상 버전은 Rust Videos YouTube 채널의 Seattle Rust User Group 발표에서 볼 수 있다. 이 프로젝트의 코드는 GitHub의 CarlKCarlK/inherit에서 확인할 수 있다.
Rust에는 클래스 상속이 없다. 이것은 좁은 의미의 언어 기능 차원에서는 사실이다. 클래스도 없고, 서브클래스 선언도 없고, 부모 클래스에서 물려받는 필드도 없다.
하지만 사람들이 객체지향 언어에서 상속을 찾을 때는 보통 다음 세 가지 효과 중 하나를 얻으려는 경우가 많다.
Rust는 세 번째를 직접 제공하지는 않는다. 하지만 첫 번째와 두 번째에 대해서는 놀랄 만큼 풍부한 도구 집합을 제공한다. 이 글에서는 상속 모양을 한 아홉 가지 문제와, 그것을 해결하는 Rust 기법들을 살펴보겠다.
덧붙임: 이 글에서는 “추상 클래스”, “인터페이스”, “trait”를 역할 또는 계약을 가리키는 세 가지 표현으로 사용하겠다. Rust에서는 보통 그 메커니즘이 trait이다. 즉, 구체 타입이 구현할 수 있는 것이다. 반대로 내가 “구체 클래스”라고 말할 때는 Rust의
struct나enum을 떠올리면 된다. 실제로 값을 만들 수 있는 타입 말이다.
이 글은 아홉 개의 작은 퍼즐을 중심으로 구성되어 있다. 각 퍼즐은 객체지향 설계로 시작한 뒤, 같은 아이디어를 Rust에서 어떻게 표현해야 하는지를 묻는다. 퍼즐은 다음과 같다.
union() 추가하기OutputArray<8>에만 바이트 지향 메서드 주기가장 단순한 경우부터 시작해 보자. 여러 타입이 같은 인터페이스를 필요로 하고, 하나의 헬퍼 메서드를 공유해야 한다.
RangeSetBlaze 크레이트는 u8, i16 같은 정수들의 수학적 집합을 저장한다. 이 크레이트는 각 정수 타입이 정의해야 하는 min_value와 max_value 같은 메서드를 통해 정수를 다룬다. 그리고 그런 필수 메서드들을 바탕으로 exhausted_range 같은 추가 메서드에 대한 공유 코드도 원한다.
객체지향 클래스 다이어그램에서는, 필수 메서드와 하나의 구현된 메서드를 가진 추상 Integer 클래스를 그리고, 구체 정수 타입들이 그것을 상속받도록 할 수 있을 것이다.
엔터를 누르거나 클릭하여 이미지를 전체 크기로 보세요

퍼즐: 이것을 Rust에서 어떻게 구현할 수 있을까?
해결책: Rust에서는 두 개의 필수 메서드와 하나의 기본 메서드 exhausted_range를 가진 trait를 정의할 수 있다.
use std::ops::RangeInclusive; trait Integer: Copy + Ord {
fn min_value() -> Self;
fn max_value() -> Self;
fn exhausted_range() -> RangeInclusive<Self> {
debug_assert!(Self::min_value() < Self::max_value(), "Precondition");
Self::max_value()..=Self::min_value()
}
}
공유 코드는 trait 안에 한 번만 존재한다. 각 구현체는 자신의 min_value와 max_value에 대한 코드를 제공한다.
impl Integer for u8 {
fn min_value() -> Self {
u8::MIN
}
fn max_value() -> Self {
u8::MAX
}
} impl Integer for i16 {
fn min_value() -> Self {
i16::MIN
}
fn max_value() -> Self {
i16::MAX
}
}
메서드는 다음처럼 사용한다.
let r1 = u8::exhausted_range();
let r2 = i16::exhausted_range();
assert_eq!(r1, 255..=0);
assert!(r2.is_empty()); 이 기법을 Trait Default Methods라고 부른다. trait가 필요한 인터페이스를 정의하고, 본문을 가진 메서드는 그것을 재정의하지 않는 구현체들에 대해 공유 동작이 된다.
그런데 더 많은 구조를 원한다면 어떨까?
device-envoy 크레이트는 마이크로컨트롤러에서 고수준 Rust 애플리케이션을 작성하는 데 도움을 준다. ESP32와 Raspberry Pi Pico 계열 마이크로컨트롤러에서 동작한다. 여러 기능 중 하나로 서보 모터 제어를 도와줄 수 있다.
서보는 특정 각도로 이동하라고 지시할 수 있는 전기 모터다. 이것을 Servo라는 추상 클래스로 모델링해 보자.
또 ServoPlayer라는 추상 클래스도 원한다. 모든 ServoPlayer는 Servo이지만, ServoPlayer는 각도와 유지 시간의 시퀀스를 따라 애니메이션하는 방법도 알고 있다.
ESP32 같은 특정 마이크로컨트롤러 계열에 대해서는 Servo와 ServoPlayer를 상속받는 구체 클래스를 원한다.
클래스 다이어그램은 다음과 같다.
엔터를 누르거나 클릭하여 이미지를 전체 크기로 보세요

퍼즐: 이것을 Rust에서 어떻게 구현할까?
**해결책:**Rust에서는 한 trait가 다른 trait를 요구할 수 있다.
trait Servo {
fn set_degrees(&self, degrees: u16);
} trait ServoPlayer: Servo {
fn animate(&self, steps: &[(u16, u64)]);
}
여기서 : Servo 부분은 모든 ServoPlayer가 또한 Servo여야 한다는 뜻이다. Rust 용어로는 ServoPlayer가 supertrait로서 Servo를 가진다.
덧붙임: 이 모든 예제에서 우리는 핵심을 설명하는 작고 독립적인 코드를 흉내 내고 있다. 전체 코드는 실제 크레이트를 보길 바란다. 이 예제에서는 실제로 서보 모터를 제어하는 대신 단지 메시지를 출력한다.
Servo만 구현하는 구체 타입은 다음과 같을 수 있다.
#[derive(Default)]
struct ServoEsp; impl Servo for ServoEsp {
fn set_degrees(&self, degrees: u16) {
println!("[ServoEsp] set angle -> {degrees}°");
}
}
ServoPlayer를 구현하는 구체 타입은 반드시 Servo도 구현해야 한다.
#[derive(Default)]
struct ServoPlayerEsp; impl Servo for ServoPlayerEsp {
fn set_degrees(&self, degrees: u16) {
println!("[ServoPlayerEsp] set angle -> {degrees}°");
}
}
impl ServoPlayer for ServoPlayerEsp {
fn animate(&self, steps: &[(u16, u64)]) {
for (degrees, _milliseconds) in steps {
self.set_degrees(*degrees);
}
}
}
덧붙임:
ServoPlayerEsp가 하나의 impl 블록에서Servo를 구현하고 다른 impl 블록에서ServoPlayer를 구현한다는 점에 주목하자. 이는 Rust가ServoPlayer: Servo를 “ServoPlayer는Servo를 요구한다”로 취급하지, “ServoPlayer가Servo구현을 상속받는다”로 취급하지 않기 때문이다.
그러면 제네릭 코드는 자신이 정확히 필요한 능력만 요구할 수 있다.
fn center_servo(servo: &impl Servo) {
servo.set_degrees(90);
} fn run_wave(player: &impl ServoPlayer) {
player.animate(&[(0, 120), (90, 100), (180, 120)]);
}
ServoPlayerEsp는 두 함수 모두에 전달할 수 있다. 일반 ServoEsp는 center_servo에만 전달할 수 있다.
let servo_esp = ServoEsp::default();
let servo_player_esp = ServoPlayerEsp::default(); center_servo(&servo_esp);
center_servo(&servo_player_esp);
run_wave(&servo_player_esp);
이 기법을 Supertraits라고 부른다. 한 trait가 다른 trait를 요구하게 하여, 클래스 계층을 만들지 않고도 인터페이스의 수준을 쌓아 올릴 수 있다. ServoPlayer는 Servo의 서브클래스는 아니지만, 모든 ServoPlayer는 Servo 계약도 만족해야 한다.
지금까지의 타입들은 우리가 설계할 수 있는 것들이었다. 이제는 이미 존재하는 타입을 다뤄 보자.
usize에 is_odd()를 추가하고 싶다고 해 보자. usize는 Rust 표준의 부호 없는 정수 타입으로, 크기와 인덱스에 사용된다.
객체지향 용어로 말하면, 새 추상 클래스로부터 usize가 새로운 메서드를 상속받게 하고 싶은 것이다.

퍼즐: 이것을 Rust에서 어떻게 할까?
덧붙임: “inherit”와 “inherent”라는 단어를 혼동하지 말자. 아래로 물려준다는 뜻의 “inherit”는 Rust가 대체로 하지 않고, 또 대체로 필요로 하지도 않는 것이다. Rust에서 “inherent”는 “타입에 직접 붙어 있는”을 뜻한다.
해결책: is_odd를 직접, 즉 inherent 방식으로 정의하려 하면 컴파일러가 불평할 것이다.
하지만 우리 자신의 trait를 정의하고 그것을 usize에 구현할 수는 있다.
trait UsizeExtensions {
fn is_odd(self) -> bool;
} impl UsizeExtensions for usize {
fn is_odd(self) -> bool {
self & 1 != 0
}
}
이제 이 메서드를 마치 usize의 일부인 것처럼 사용할 수 있다.
let count: usize = 7;
assert!(count.is_odd());
assert!(!12.is_odd()); 관례적으로 이것을 Extension Trait라고 부른다. 하지만 컴파일러 입장에서는 그저 trait일 뿐이다. 이 관례는 “나는 타입에 메서드를 추가하고 있다. 종종 내가 소유하지 않은 타입에 대해서도”라는 뜻을 담고 있어 유용하다.
덧붙임: 왜 컴파일러는 내가 소유하지 않은 타입에 trait를 구현하는 것은 허용하면서, 직접적인 inherent 메서드 정의는 허용하지 않을까? 우리가
usize자체를 바꾸는 것이 아니기 때문이다. 우리는 우리 자신의 trait를 정의하고, “usize는 이 역할을 수행할 줄 안다”라고 말하는 것이다. 다른 크레이트가 또 다른 trait를 정의해서usize에 구현할 수도 있지만, 그것은 다른 역할이다.직접 메서드는 다르다. 모든 크레이트가
usize에 직접 메서드를 추가할 수 있다면, 많은 크레이트가 같은 메서드 목록을 편집하게 된다. 두 크레이트가 서로 다른is_odd메서드를 추가할 수도 있고, Rust 자체가 나중에 하나를 추가할 수도 있다. 이런 혼란을 피하기 위해 Rust는 타입을 정의한 크레이트에만 직접적인 inherent 메서드 추가를 허용한다.
메서드는 이야기의 일부일 뿐이다. 때로는 어떤 타입이 Rust 생태계의 나머지 부분에서도 잘 동작해야 한다.
작은 enum에는 보통 표준 동작들이 필요하다. 기본값, 디버그 출력, 동등성, 순서 비교, 해싱, 복사, 클론 등이다.
클래스 계층에서는 이것을 여러 추상 클래스가 하나의 구체 타입에 섞여 들어가는 형태로 그릴 수 있을 것이다.
퍼즐: LedLevel에 이런 표준 동작들을 각각 손으로 구현하지 않고 모두 주려면 어떻게 할까?
엔터를 누르거나 클릭하여 이미지를 전체 크기로 보세요

**해결책:**Rust에서는 표준 trait들이 이미 존재하고, derive가 그 구현을 대신 작성해 준다.
enum LedLevel {
On,
Off,
} 우리는 파생된 trait들을 다음처럼 사용한다.
let default_level = LedLevel::default();
let on = LedLevel::On;
let off = LedLevel::Off; assert_eq!(default_level, LedLevel::Off);
assert_ne!(on, off);
assert!(off > on);
let copied = on;
let cloned = off.clone();
assert_eq!(copied, on);
assert_eq!(cloned, off);
이것은 LedLevel이 이제 기본값 설정, 출력, 비교, 해싱, 복사, 클론 같은 여러 표준 동작에 참여한다는 점에서 상속과 비슷하다. 하지만 Rust의 메커니즘은 클래스 상속과 다르다. derive 속성은 매크로에게 이 구체 타입에 대한 일반 trait 구현을 생성하라고 요청한다.
이 기법을 Derive-Generated Implementations라고 부른다. Rust가 어떤 타입에 표준 동작 묶음을 부여하는 가장 흔한 방법 중 하나다.
지금까지는 상속과 비슷한 동작이 trait를 통해 이루어졌다. 하지만 때로는 더 강한 무언가를 원할 수도 있다. “이 구체 타입이 저 구체 타입을 상속받게 하고 싶다” 같은 것이다.
이제는 조금 다른 종류의 퍼즐이다. “상속받고 싶은” 대상이 trait가 아니라 구체 타입 그 자체라면 어떨까?
HtmlBuffer가 별개의 타입으로 남으면서도, 일반적인 메서드 호출에서는 String처럼 느껴지기를 원한다고 해 보자. 문제는 String이 trait가 아니라 저장소를 가진 구체 타입이라는 점이다.
객체지향 용어로는, 하나의 구체 클래스 HtmlBuffer가 다른 구체 클래스 String의 메서드를 상속받게 하고 싶은 것이다.

퍼즐: String이 trait가 아니라 구체 타입인데도, HtmlBuffer가 push_str, len, as_bytes 같은 일반적인 String 메서드를 상속받게 하려면 어떻게 해야 할까?
해결책: String은 trait가 아니므로 HtmlBuffer에 대해 String을 구현할 수는 없다.
하지만 String을 감싸고 Deref와 DerefMut를 구현할 수는 있다.
use std::ops::{Deref, DerefMut}; struct HtmlBuffer(String);
impl HtmlBuffer {
fn new() -> Self {
Self(String::new())
}
}
impl Deref for HtmlBuffer {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl DerefMut for HtmlBuffer {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
이제 Rust의 deref 메서드 조회 덕분에 HtmlBuffer에서 많은 String 메서드를 호출할 수 있다.
let mut page = HtmlBuffer::new(); page.push_str("<h1>Hello</h1>");
page.push_str("<p>Rust</p>");
assert_eq!(page.len(), 25);
assert_eq!(&*page, "<h1>Hello</h1><p>Rust</p>");
이 기법을 Deref Method Lookup이라고 부른다. 이것은 강력하며, Box, Rc, Arc 같은 스마트 포인터에는 정확히 잘 맞는다. 하지만 HtmlBuffer에 대해 Deref를 사용하는 것은 두 가지 이유로 현명하지 않을 수 있다.
이 작성자의 업데이트를 받으려면 Medium에 무료로 가입하세요.
더 빠른 로그인을 위해 나를 기억하기
첫째, 이것만으로는 HtmlBuffer가 모든 문맥에서 String처럼 동작하게 되지는 않는다. 예를 들어 위의 마지막 줄은, 이 동등성 비교를 위해 우리가 명시적으로 역참조해야 함을 보여 준다.
assert_eq!(&*page, "<h1>Hello</h1><p>Rust</p>");
여기서 *page는 HtmlBuffer 안의 String을 꺼내 달라는 뜻이고, 그 뒤의 &는 비교를 위해 그 String을 빌린다. 즉, Deref는 메서드 조회에는 도움을 주지만 HtmlBuffer를 String과 동일하게 만들지는 않는다.
둘째, Deref는 종종 지나치게 허용적이다. 래퍼 타입은 보통 두 의미를 분리하기 위해 존재한다. HTML 버퍼는 그냥 아무 문자열이나 되는 것이 아니다. deref 조회를 통해 String의 전체 인터페이스를 노출하면, 그 구분이 약해질 수 있다. as_str 같은 더 명시적인 메서드가 의도를 더 잘 전달할 수도 있다.
덧붙임: 관련 trait로는
AsRef,From,Borrow가 있다.AsRef는 빌린 뷰를 노출할 수 있고,From은 변환을 표현할 수 있으며,Borrow는 조회 스타일의 borrowing을 지원할 수 있다. 이런 trait들은 보통Deref보다 더 명시적이지만, 그래도 주의해서 써야 한다.
지금까지는 한 번에 하나의 타입에 동작을 추가하는 데 신중했다. 하지만 때로는 그 반대가 필요하다. 하나의 메서드가 타입들의 전체 집합에 나타나게 하고 싶은 것이다.
union() 추가하기RangeSetBlaze 값이 많이 있고, 그 전체 합집합을 구하고 싶다고 하자. 이것은 벡터, 배열, 또는 RangeSetBlaze 참조를 반복할 수 있는 다른 어떤 iterable 컬렉션에서도 동작해야 한다.
객체지향 설계로 보면, 범위 집합의 어떤 iterable 컬렉션이든 union 연산을 상속받아야 한다.
엔터를 누르거나 클릭하여 이미지를 전체 크기로 보세요

퍼즐: 미리 예상하지 못한 컬렉션 타입까지 포함해서, RangeSetBlaze 참조를 반복할 수 있는 모든 컬렉션에 union() 메서드를 어떻게 추가할까?
**해결책:**이 예제에서는 RangeSetBlaze를 BTreeSet<u64>를 감싼 래퍼로 흉내 낸다.
use std::collections::BTreeSet; type Integer = u64;
#[derive(Debug, Clone, PartialEq, Eq)]
struct RangeSetBlaze {
values: BTreeSet<Integer>,
}
이 모의 타입을 바탕으로 컬렉션 trait를 정의할 수 있다. 이 trait는 빌린 RangeSetBlaze 값들을 순회하는 반복자로 변환될 수 있는 모든 타입에 대해 union을 제공한다.
trait RangeSetCollection<'a>: IntoIterator<Item = &'a RangeSetBlaze> {
fn union(self) -> RangeSetBlaze
where
Self: Sized,
{
let mut result = RangeSetBlaze::new();
for set in self {
result = RangeSetBlaze::union(&result, set);
}
result
}
} 마지막으로 제네릭을 사용한 “blanket implementation”을 추가한다.
impl<'a, I> RangeSetCollection<'a> for I
where I: IntoIterator<Item = &'a RangeSetBlaze> {}
이 말은 곧, &RangeSetBlaze의 반복자로 변환될 수 있는 모든 타입 I가 이 trait를 갖는다는 뜻이다.
이제 벡터도 동작한다.
assert_eq!(vec![&a, &b, &c].union(), expected); 배열도 동작한다.
assert_eq!([&a, &b, &c].union(), expected);
심지어 Rust가 Option에 대해서도 반복을 허용하므로, 이렇게 필터링한 Option도 동작한다.
assert_eq!(Some(&a).filter(|set| !set.is_empty()).union(), a); 이 기법을 Blanket Implementations라고 부른다. 이것은 한 타입에 대한 확장 trait보다 더 넓다. 조건을 만족하는 모든 타입에 동작을 추가하며, 우리가 미리 예상하지 못한 타입도 포함된다.
RangeSetBlaze는 15개의 정수 비슷한 타입을 동일하게 다뤄야 한다. 숫자 정수 기본 타입들, char, Ipv4Addr, Ipv6Addr가 그것이다. 각각에는 add_one, min_value, max_value가 필요하다.
객체지향 용어로는 Integer 추상 클래스를 그리고, 그 아래에 숫자 정수, IP 주소 정수, 문자 정수를 위한 더 작은 추상 클래스들을 둘 수 있을 것이다. 각 계열은 하나의 구현 패턴을 공유할 수 있다.
엔터를 누르거나 클릭하여 이미지를 전체 크기로 보세요

퍼즐: 같은 메서드 본문을 15번 쓰지 않으면서, 15개 타입 모두에 같은 Integer 인터페이스를 어떻게 줄 수 있을까?
**해결책:**trait 자체는 단순하다.
trait Integer: Copy + Ord {
fn add_one(self) -> Self;
fn min_value() -> Self;
fn max_value() -> Self;
}
구현은 계열마다 다르다. 숫자 정수는 self + 1로 하나를 더하고 MIN과 MAX를 사용할 수 있다.
macro_rules! impl_integer_ops_num {
($t:ty) => {
fn add_one(self) -> Self {
self + 1
} fn min_value() -> Self {
<$t>::MIN
}
fn max_value() -> Self {
<$t>::MAX
}
};
}
IP 주소는 숫자 표현 타입을 사용한다.
macro_rules! impl_integer_ops_ip {
($ip_type:ty, $representation_type:ty) => {
fn add_one(self) -> Self {
<$ip_type>::from(<$representation_type>::from(self) + 1)
} fn min_value() -> Self {
<$ip_type>::from(<$representation_type>::MIN)
}
fn max_value() -> Self {
<$ip_type>::from(<$representation_type>::MAX)
}
};
}
char에는 특별한 경우가 하나 있다. 일부 u32 값은 유효한 유니코드 스칼라 값이 아니므로, add_one은 이 범위를 건너뛰어야 한다.
macro_rules! impl_integer_ops_char {
() => {
fn add_one(self) -> Self {
let mut num = u32::from(self) + 1;
if num == 0xD800 {
num = 0xE000;
}
char::from_u32(num)
.expect("next char must be a valid Unicode scalar value")
} fn min_value() -> Self {
char::MIN
}
fn max_value() -> Self {
char::MAX
}
};
}
덧붙임:
0xD800..0xE000은 유니코드의 surrogate 범위다. 이 값들은 16비트 코드 유닛을 사용하는 유니코드 인코딩인 UTF-16을 위해 예약되어 있다. 그 자체로는 실제 문자가 아니므로 Rust의char는 이들을 건너뛴다.
그다음에도 각 대상 타입마다 하나의 impl은 써야 하지만, 각각은 한 줄뿐이다.
impl Integer for i8 { impl_integer_ops_num!(i8); }
impl Integer for u8 { impl_integer_ops_num!(u8); }
impl Integer for i16 { impl_integer_ops_num!(i16); }
impl Integer for u16 { impl_integer_ops_num!(u16); }
impl Integer for i32 { impl_integer_ops_num!(i32); }
impl Integer for u32 { impl_integer_ops_num!(u32); }
impl Integer for i64 { impl_integer_ops_num!(i64); }
impl Integer for u64 { impl_integer_ops_num!(u64); }
impl Integer for i128 { impl_integer_ops_num!(i128); }
impl Integer for u128 { impl_integer_ops_num!(u128); }
impl Integer for isize { impl_integer_ops_num!(isize); }
impl Integer for usize { impl_integer_ops_num!(usize); }
impl Integer for Ipv4Addr { impl_integer_ops_ip!(Ipv4Addr, u32); }
impl Integer for Ipv6Addr { impl_integer_ops_ip!(Ipv6Addr, u128); }
impl Integer for char { impl_integer_ops_char!(); } 이 기법을 Macro-Generated Implementations라고 부른다. 코드 공유를 위해 trait 계층을 쓰는 대신, 알려진 타입 목록에 대해 비슷한 trait impl들을 생성하도록 매크로를 사용한다.
덧붙임: 왜 trait를 쓰지 않을까? 서드파티
num_traits크레이트는 숫자 정수 타입들이 구현하는PrimInttrait를 정의한다. 퍼즐 6처럼PrimInt와 blanket implementation을 사용해 세 개의 숫자 메서드 본문을 단 한 번만 쓸 수 있을까? 안전하게는 어렵다.PrimInt는 다른 크레이트에 속하므로, Rust는 미래의 구현도 고려해야 한다. 만약 언젠가char가PrimInt를 구현하게 되면, 우리의 blanket 숫자 impl과 특별한charimpl이 둘 다 적용될 것이다. 로컬PrimInt비슷한 trait도 괜찮은 선택이지만, 그래도 모든 계열에 대해 깔끔한 blanket impl을 주지는 못한다. Rust는PrimInt와 가상의PrimIp같은 두 trait가 서로 겹치지 않는다는 것을 증명할 수 없기 때문이다.
매크로는 알려진 타입 목록에 걸쳐 코드를 공유하게 해 준다. 다음으로는 제네릭 타입의 한 버전에만 메서드가 나타나게 해 보겠다.
OutputArray<8>에만 바이트 지향 메서드 주기이건 원래 나도 Rust에서 가능하다는 것을 몰랐던 것이다.
OutputArray<N>이 고정 크기의 불리언 배열이라고 해 보자. 모든 길이는 new와 set_level_at_index를 지원해야 한다. 하지만 N = 8일 때는 set_from_bits(u8)도 원한다. 여덟 개의 불리언이 하나의 바이트에 자연스럽게 대응되기 때문이다.
객체지향 용어로는 OutputArray<8>을 추가 메서드 하나를 가진 특별한 구체 클래스로 그릴 수 있을 것이다.
엔터를 누르거나 클릭하여 이미지를 전체 크기로 보세요

퍼즐: 이 메서드를 모든 OutputArray<N>에 주지 않으면서 OutputArray<8>에만 추가하려면 어떻게 할까?
해결책: 일반 메서드들은 일반 impl 블록에 들어간다.
#[derive(Debug, Clone, Copy)]
struct OutputArray<const N: usize> {
levels: [bool; N],
} impl<const N: usize> OutputArray<N> {
fn new() -> Self {
Self { levels: [false; N] }
}
fn set_level_at_index(&mut self, index: usize, level: bool) {
self.levels[index] = level;
}
}
길이 8 전용 메서드는 별도의 impl 블록에 넣는다.
impl OutputArray<8> {
fn set_from_bits(&mut self, mut bits: u8) {
for slot in &mut self.levels {
*slot = (bits & 1) == 1;
bits >>= 1;
}
}
}
이제 OutputArray::<4>에는 new와 set_level_at_index는 있지만 set_from_bits는 없다. OutputArray::<8>에는 세 메서드가 모두 있다.
let mut any = OutputArray::<4>::new();
any.set_level_at_index(2, true); let mut eight = OutputArray::<8>::new();
eight.set_from_bits(0b1011_0001);
이 기법을 Constraint-Gated Methods라고 부른다. 이 메서드는 조건을 만족하는 타입 버전에만 존재한다. 여기서 조건은 N = 8이지만, 같은 기본 아이디어는 trait bounds, lifetime, 그리고 여러 조건의 조합에도 적용된다.
지금까지는 제약이 타입의 특별한 버전을 골라냈다. 다음으로는 타입 자체는 평범하게 두되, 일부 메서드만 추가 bounds를 요구하게 해 보자.
device-envoy에서 나는 값을 플래시 메모리에 쓰고 싶었다. 플래시 블록은 항상 new와 clear를 가져야 한다. 하지만 save와 load는 직렬화와 역직렬화가 가능한 타입에 대해서만 동작해야 한다.
객체지향 용어로는 new와 clear를 가진 한 수준과, 값 타입이 직렬화와 역직렬화를 지원할 때만 save와 load를 더하는 더 제약된 수준을 그릴 수 있을 것이다.
엔터를 누르거나 클릭하여 이미지를 전체 크기로 보세요

퍼즐: 어떤 메서드의 타입 매개변수가 필요한 능력을 가질 때에만 일부 메서드가 사용 가능하도록 하려면 어떻게 할까?
**해결책:**이 예제는 serde와 postcard를 사용하며, 플래시 메모리 대신 HashMap을 사용한다.
use std::collections::HashMap; use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
struct WifiCredentials {
ssid: String,
password: String,
}
#[derive(Default)]
struct FlashBlock {
store: HashMap<String, Vec<u8>>,
}
타입 자체는 제네릭이 아니다. 제네릭인 것은 메서드들이다.
impl FlashBlock {
fn new() -> Self {
Self::default()
} fn clear(&mut self) {
self.store.clear();
}
fn save<T>(&mut self, key: &str, value: &T) -> Result<(), postcard::Error>
where
T: Serialize + DeserializeOwned,
{
let bytes = postcard::to_stdvec(value)?; self.store.insert(key.to_string(), bytes); Ok(())
}
fn load<T>(&self, key: &str) -> Option<T>
where
T: Serialize + DeserializeOwned,
{
let bytes = self.store.get(key)?;
postcard::from_bytes(bytes).ok()
}
}
이것은 꽤 잘 동작한다.
let mut flash = FlashBlock::new(); let credentials = WifiCredentials {
ssid: "HomeWiFi".to_string(),
password: "secret".to_string(),
};
flash.save("wifi", &credentials)?;
let loaded: Option<WifiCredentials> = flash.load("wifi");
let loaded = loaded.unwrap();
assert_eq!(&loaded.ssid, "HomeWiFi");
assert_eq!(&loaded.password, "secret");
하지만 이 유연성에는 대가가 있다. 예를 들어 숫자를 저장해 놓고 그것을 String으로 다시 읽어 오려고 시도할 수 있다. 컴파일러는 불평하지 않는다.
flash.save("number", &42 u8)?;
let loaded: Option<String> = flash.load("number");
assert!(loaded.is_none());
덧붙임: 이 실행에서는
loaded가None인데, 엉뚱한 문자열을 돌려주는 것보다는 낫다. 하지만 컴파일러는 키와 요청한 타입이 일치한다는 것을 증명할 수 없다. 실제 플래시 저장소에서는 또 다른 걱정도 있다. 더 오래된 펌웨어 버전이 바이트를 썼을 수도 있기 때문이다. 실제device-envoy코드는 값과 함께 메타데이터를 저장해서load가 많은 타입 또는 버전 불일치를 감지하고None을 반환할 수 있게 한다.
이 기법을 Method-Level Constraints라고 부른다. 타입 자체는 넓게 사용 가능하게 둔 채, 개별 메서드가 자신이 요구하는 추가 능력을 밝힐 수 있다.
정리해 보자.
Trait Default Methods
Supertraits
Extension Traits
Derive-Generated Implementations
Deref Method Lookup
union() 추가하기Blanket Implementations
Macro-Generated Implementations
OutputArray<8>에만 바이트 지향 메서드 주기Constraint-Gated Methods
Method-Level Constraints
처음에는 이것들을 Rust에서 상속을 하는 아홉 가지 방법이라고 생각했다. 하지만 끝에 와서는 그 목록이 그보다 더 작아 보였다. 대부분의 기법은 사실 몇 가지 아이디어의 조합일 뿐이다.
Rust에는 클래스 상속이 없다. 하지만 상속에서 원했던 것이 공유 인터페이스와 공유 동작이었다면, Rust에는 그것을 위한 구성 요소들이 있다. 요령은 그 구성 요소들을 조합해 자신이 의미하는 바를 말하게 만드는 것이다.
덧붙임: 때로는 이런 구성 요소들을 함께 놓았을 때 Rust 컴파일러가 불평할 수 있다. Rust에서는 그와 관련된 규칙 집합을 coherence라고 부른다. coherence는 주어진 타입과 trait의 조합마다 적용되는 impl이 하나로 명확하기를 Rust가 원한다는 뜻이다.
객체지향 언어는 종종 메서드 조회를 클래스 계층으로 보내는 방식으로 이 특정 문제를 피한다.
Dog가Animal을 상속받고 둘 다speak를 정의한다면, 어떤 메서드가 이기는지에 대한 규칙이 언어에 있다.
내게 이 글의 교훈은 “Rust에서 클래스를 어떻게 재현할까?”가 아니다. 오히려 객체지향 설계를 Rust로 번역하는 단계에서 벗어나, trait, bounds, impls, generics, 명시적 동작이라는 Rust 고유의 언어로 더 유창하게 사고하는 방법에 관한 것이다.
Rust에서의 상속 비슷한 동작을 둘러보는 이 여정에 함께해 주어 고맙다.
덧붙임: 앞으로의 글에 관심이 있다면,Medium에서 저를 팔로우해 주세요. 저는 과학 프로그래밍, Rust, Python, 머신러닝, 통계에 대해 씁니다. 대체로 한 달에 한 편 정도 글을 씁니다.