함수 코드는 한 군데에만 있다
함수 코드는 프로그램 시작 시 바이너리의 text segment(code segment)에 한 번 로드된다. instance를 100개 만들든 100만 개 만들든 method의 기계어 코드는 메모리 한 곳에만 존재한다. 이건 어느 언어든 마찬가지다.
따라서 instance method와 associated function의 메모리상 차이는 코드 위치가 아니라 호출 시 첫 인자가 어떻게 세팅되는가일 뿐이다.
instance method 호출의 실제
rect1.area() (signature: fn area(&self) -> u32)가 컴파일되어 실행될 때 일어나는 일을 x86-64 SystemV ABI 기준으로 보면:
- caller가
rect1의 주소를 첫 인자 register(rdi)에 로드한다. call Rectangle::area— text segment의area함수 주소로 점프한다.- 함수 본문에서
self.width는rdi가 가리키는 메모리에서 width 필드의 offset(0)에 해당하는 4바이트를 읽는 동작으로 컴파일된다. - height도 동일하게 offset(4)에서 읽는다.
- 곱한 값을 리턴 register(
rax)에 넣고 복귀한다.
C로 이 과정을 그대로 적으면 다음과 같다.
uint32_t Rectangle_area(const Rectangle *self) {
return self->width * self->height;
}
Rectangle_area(&rect1);
Rust method는 본질적으로 이 코드와 같은 어셈블리로 컴파일된다. 점 호출 syntax와 namespacing이 추가됐을 뿐이다.
associated function 호출의 실제
Rectangle::square(3) (signature: fn square(size: u32) -> Self)는 더 단순하다.
- caller가
3을 첫 인자 register에 로드한다. call Rectangle::square로 점프한다.- 함수 본문이 stack/register에 새
Rectangle { width: 3, height: 3 }데이터를 만들어 리턴값으로 넘긴다. - caller가 그 리턴값을 자기 변수 자리에 복사한다.
이건 그냥 일반 함수 호출이다. self가 없으니 인스턴스 주소를 넘길 일도 없다. Java의 static method, Python의 @staticmethod, C의 함수와 호출 모델이 동일하다.
인스턴스 메모리에는 타입 정보가 없다
런타임에 "Rectangle 타입에 area라는 method가 있다"는 정보는 어디에도 저장되지 않는다. 모든 게 컴파일 타임에 resolve된다.
Rust의 Rectangle (stack):
+0x00: width (u32, 4 bytes)
+0x04: height (u32, 4 bytes)
총 8 bytes. 끝.
Java라면:
Java의 Rectangle (heap):
+0x00: object header (mark word, 8 bytes)
+0x08: class pointer ── 여기서 vtable로 연결
+0x10: width
+0x14: height
Rust 인스턴스에는 클래스 포인터도 vtable도 없다. 데이터 필드만 있다. method를 추가해도 instance 메모리는 늘어나지 않는다 — zero-cost abstraction이 method에서도 그대로 적용된다.
정적 dispatch가 기본
rect1.area()를 컴파일할 때 Rust는 다음을 확정한다.
rect1의 타입은Rectangle이다.Rectangle::area의 정확한 주소는 컴파일 타임에 알 수 있다.
따라서 호출은 그 주소로 직접 점프하는 단일 call 명령으로 컴파일된다. vtable lookup이 없다. C 함수 호출과 비용이 정확히 같다.
이는 Java의 instance method와 정반대다. Java는 모든 instance method가 기본 virtual이므로 호출 때마다 객체 헤더 → 클래스 포인터 → vtable → 함수 주소 순으로 lookup한다. Rust는 이걸 안 한다.
C++의 non-virtual member function과는 메모리/dispatch 모델이 동일하다. C++에서 virtual 키워드를 안 붙인 method가 Rust method의 기본 동작에 매칭된다.
dynamic dispatch가 일어나는 유일한 경우
trait object를 명시적으로 사용할 때만 vtable이 등장한다.
let shape: Box<dyn Shape> = Box::new(Rectangle { width: 30, height: 50 });
shape.area(); // 이 호출은 vtable을 거친다
Box<dyn Shape>는 일반 포인터가 아니라 fat pointer — (data pointer, vtable pointer) 두 워드로 구성된 포인터다. vtable에 method 함수 주소가 박혀있고 호출 시 거기서 lookup한다. 이때만 Java의 instance method와 비슷한 비용이 발생한다.
대신 impl Shape for Rectangle로 정의된 method를 generic으로 호출하면(fn foo<T: Shape>(x: T)) 컴파일러가 monomorphization을 통해 각 구체 타입마다 별도의 함수 사본을 만들고, 호출은 다시 정적 dispatch가 된다. 코드 크기는 늘지만 vtable 비용이 없다.
다른 언어와의 비교 정리
| 언어 / 호출 형태 | dispatch 방식 | 인스턴스 메모리 부담 |
|---|---|---|
| Rust method (기본) | 정적 (compile-time resolve) | 데이터 필드만 |
Rust dyn Trait | 동적 (vtable via fat pointer) | 데이터 필드만, 포인터에 vtable |
| C 함수 + struct ptr | 정적 | 데이터 필드만 |
| C++ non-virtual member | 정적 | 데이터 필드만 |
| C++ virtual member | 동적 (vtable via class) | 데이터 + vtable 포인터 |
| Java instance method | 동적 (vtable via class) | 데이터 + 헤더(클래스 포인터) |
| Java static method | 정적 | 해당 없음 |
| Python instance method | 동적 (dict lookup) | 인스턴스 dict + 클래스 ref |
Python @staticmethod | 동적 (dict lookup) | 해당 없음 |
Rust는 "instance method도 기본은 정적 dispatch"라는 점에서 OOP 계열 언어들 중에서 독특한 위치에 있다. C++ 중에서도 non-virtual에 해당하는 영역만 기본값으로 채택한 셈이다.
결론
Rust method는 컴파일 타임에 Type::method(&instance, args...) 형태로 풀리는 일반 함수다. instance 메모리에는 데이터 필드만 있고 타입/method 정보는 없으며, dispatch는 기본 정적이라 호출 비용이 C 함수 호출과 동일하다. dynamic dispatch는 trait object를 쓸 때만 발생한다 — 이는 Java가 기본 virtual인 것과 정반대다.
associated function (self 없음)은 그냥 namespacing된 일반 함수로, 인스턴스 메모리와 무관하게 동작한다.