Rust에서 ‘메서드’를 트레이트에 넣었을 때 객체 안전한 연관 함수로 한정해 부르자는 제안과 함께, 자유 함수·연관 함수·리시버, 제네릭과 정적 디스패치, 동적 디스패치와 객체 안전성의 관계를 예제로 설명한다.
나는 Rust에서 “method(메서드)”라는 말을, 트레이트에 넣었을 때 객체 안전(object safe)할 연관 함수(associated function)를 가리키는 용어로 한정해 쓰는 편이 좋다고 생각한다.
이 용어 체계에서:
핵심은 이것뿐이다. 위 내용이 낯설다면, 계속 읽어보자.
친구와 객체지향 프로그래밍 이야기를 하다가, 프로그래머들이 종종 “OOP”를 하나의 거대한 개념으로 여기는 태도가 왜 이상하게 느껴지는지 Rust로 설명해 보고 싶었다. 사실 OOP는 취향에 따라 넣거나 뺄 수 있는 여러 개념을 싸서 먹는 ‘월남쌈’ 같은 것이기 때문이다.
예를 들어 Rust에서는, 소위 말하는 자유 함수(free function)를 다음처럼 쓰는 것이 완전히 가능하고, 실제로 매우 흔하다:
// counter_free.rs
pub struct Counter {
pub count: u32,
}
pub fn increment(c: Counter) -> Counter {
Counter { count: c.count + 1 }
}
mod test {
use super::{Counter, increment};
#[test]
fn count_up() {
let c = Counter { x: 0 };
let c = increment(c);
assert_eq!(c.count, 1);
}
}
이걸 어떤 의미에서든 “객체지향적”이라고 부르기는 어렵다. 물론 어떤 독자들은 열정적으로 정정해 줄지도 모르겠다(환영한다!). Counter는 자신의 데이터를 캡슐화하지도 않고, 그 행동 역시 어떤 방식으로도 데이터에 묶여 있지 않다.
우리는 또한 연관 함수(associated function)도 쓸 수 있다. 이것은 자유 함수와 비슷하지만 이름이 다르고 impl 블록 안에 쓴다. 컴파일러는 그 함수가 “어떤 타입에 대한 것인지”를 알기 때문에, 그 타입을 그 함수 안에서 사용할 때 타입 이름 대신 Self라고 쓸 수 있는 편의도 제공한다.
// counter_associated.rs
pub struct Counter {
pub count: u32,
}
impl Counter {
pub fn increment(c: Self) -> Self {
Self { count: c.count + 1 }
}
}
mod test {
use super::Counter;
#[test]
fn count_up() {
let c = Counter { x: 0 };
let c = Counter::increment(f);
assert_eq!(c.count, 1);
}
}
여기서도 Counter는 여전히 데이터를 캡슐화하지 않는다. 하지만 그 행동(여기서는 increment)이 최소한 이름 차원에서는 Counter라는 타입에 묶였다. 같은 모듈 안에 Cycle::increment를 정의해도 혼동되지 않을 것이다. 이것이 OOP인가? 나는 아니라고 본다. 사실상 함수 이름만 바꿨고, 임포트가 약간 더 편해졌을 뿐이다.
그런데 Rust는 상황을 바꾸는 또 다른 특별한 문법을 제공한다. 바로 self 인수, 즉 리시버(receiver)다. 예시는 다음과 같다:
// counter_receiver.rs
pub struct Counter {
count: u32,
}
impl Counter {
pub fn new() -> Self {
Self { count: 0 }
}
pub fn count(&self) -> u32 {
self.count
}
pub fn increment(self) -> Self {
Self { count: self.count + 1 }
}
}
mod test {
use super::Counter;
#[test]
fn count_up() {
let c = Counter::new();
let c = c.increment();
assert_eq!(c.count(), 1);
}
}
이제는 훨씬 더 객체지향 프로그래밍처럼 보인다. 데이터는 캡슐화되어 있고, 사용자 입장에서는 Counter 내부에 카운트를 저장하는 복잡하고 메모리를 절약하는 어떤 방식이 있을지 몰라도, Counter::count가 호출될 때 u32로만 드러난다.
하지만—이게 Rust식 의미에서 객체지향일까? Rust에서 우리는 “object(객체)”를 매우 구체적으로 사용한다. 즉 dyn Trait가 이름에 포함된 타입을 뜻한다. 물론 Counter는 트레이트를 구현할 수 있다. 이를 Increment라고 하자.
// counter_trait.rs
pub struct Counter {
count: u32,
}
impl Counter {
pub fn new() -> Self {
Self { count: 0 }
}
pub fn count(&self) -> u32 {
self.count
}
}
pub trait Increment {
fn increment(self) -> Self;
}
impl Increment for Counter {
fn increment(self) -> Self {
Self { count: self.count + 1 }
}
}
mod test {
use super::{Counter, Increment};
#[test]
fn count_up() {
let c = Counter::new();
let c = c.increment();
assert_eq!(c.count(), 1);
}
}
여기서도 크게 달라진 것은 없다. 우리는 간접적으로 increment의 이름을 또 바꿨다. 이제 Increment::count 혹은 <Count as Increment>::increment처럼 지칭할 수 있다.
또한 프로그래머인 우리에게 약간의 유연성도 생겼다. Increment는 서로 다른 많은 타입에 적용될 수 있으니, 제네릭을 사용해 코드를 조금 더 미래 지향적으로 만들 수 있다. 예를 들어 다음과 같이 쓸 수 있다:
// counter_trait.rs (append)
fn double_count<Inc: Increment>(i: Inc) -> Inc {
i.increment().increment()
}
#[test]
fn counter_double_count() {
let c = Counter::new();
let c = double_count(c);
assert_eq!(c.count(), 2);
}
그리 새롭지는 않지만, Increment인 다른 어떤 타입과도 똑같이 잘 작동한다는 점이 흥미롭다. 예를 들어:
// counter_trait.rs (append)
pub struct Turnstyle {
entries: u32,
exits: u32,
}
impl Turnstyle {
fn new() -> Self {
Self {
entries: 0,
exits: 0,
}
}
fn occupants(&self) -> u32 {
assert!(self.entries >= self.exits);
self.entries - self.exits
}
}
impl Increment for Turnstyle {
fn increment(self) -> Self {
Self {
entries: self.entries + 1,
exits: self.exits
}
}
}
#[test]
fn turnstyle_double_count() {
let t = Turnstyle::new();
let t = double_count(t);
assert_eq!(t.occupants(), 2);
}
이제 Java 같은 언어가 객체지향 패러다임을 사용하는 방식에 많이 가까워졌다. double_count는 호출되는 데이터 타입이 무엇인지는 크게 신경 쓰지 않고, 그것이 Increment만 구현되어 있으면 된다. 컴파일러는 이를 단형화(모노모픽화, monomorphization) 과정에서 Counter용 버전 하나와 Turnstyle용 버전 하나를 생성한다.
하지만 Java와 달리, 우리는 Increment 자체(예: Vec)의 컬렉션을 만들 수 없다. 반드시 Vec<Counter>처럼 구체 타입을 명시하든가, 동적 디스패치를 사용해야 한다.
여기에서 기존의 Increment 정의는 한계를 드러낸다. 그런 컬렉션을 만들려고—예를 들어, 다양한 Increment 타입을 Box로 힙에 넣으려고—하면 매우 흥미로운 컴파일 오류를 만나게 된다.
// counter_trait.rs (append)
fn increment_all(items: Vec<Box<dyn Increment>>) {
items.into_iter().map(|i| i.increment()).collect()
}
// ERROR: `Increment` cannot be made into an object
// note: for a trait to be "object safe" it needs to allow building a vtable to
// allow the call to be resolvable dynamically
Rust에서는 타입이 아니라 트레이트로 값을 묶어 다루는 일이 동적 디스패치를 통해 이루어진다. Box, Arc 등과 같은 타입은 데이터에 대한 포인터와, 그 데이터와 연관된 동작에 대한 포인터를 함께 저장할 수 있게 해 주는데, 이 포인터 집합을 vtable(가상 메서드 테이블)이라고 부른다. 컴파일러는 increment_all을 생성하는 시점에 Increment를 구현하는 모든 타입을 반드시 알고 있지는 않으므로, 단형화를 선택할 수 없다.
객체 안전한 버전의 Increment라면 &mut self를 받아 Counter, Turnstyle, 혹은 다른 값을 제자리에서 수정하도록 해야 한다. Java의 유사한 메서드처럼 말이다.
그런 이유로, 트레이트 객체(예: &dyn Increment, Box<dyn Increment> 등)를 정의하는 데 사용되는 트레이트의 모든 함수는 &self, Box<Self> 같은 포인터 리시버를 받아야 한다.
이 때문에 나는 객체 안전할 연관 함수들만을 “메서드”라고 부르자고 주장한다. 이들은 Rust에서 우리가 “객체”라고 부르는 것과 함께 동작할 수 있고, 또 객체지향 언어에서 “메서드”가 흔히 내포하는 동적 방식으로 사용할 수 있다는 점에서, “객체지향적”으로 사용할 수 있는 연관 함수들이기 때문이다.
The Rust Reference 6.15 Associated Items - Associated functions and methods: https://doc.rust-lang.org/reference/items/associated-items.html#associated-functions-and-methods ↩︎
Rust by Example 9.1 Associated functions & Methods: https://doc.rust-lang.org/rust-by-example/fn/methods.html ↩︎
The Rust Reference 6.11 Traits - Object Safety: https://doc.rust-lang.org/reference/items/traits.html#object-safety ↩︎