learn

Rust method는 메모리에서 어떻게 관리되는가

함수 코드는 한 군데에만 있다

함수 코드는 프로그램 시작 시 바이너리의 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 기준으로 보면:

  1. caller가 rect1의 주소를 첫 인자 register(rdi)에 로드한다.
  2. call Rectangle::area — text segment의 area 함수 주소로 점프한다.
  3. 함수 본문에서 self.widthrdi가 가리키는 메모리에서 width 필드의 offset(0)에 해당하는 4바이트를 읽는 동작으로 컴파일된다.
  4. height도 동일하게 offset(4)에서 읽는다.
  5. 곱한 값을 리턴 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)는 더 단순하다.

  1. caller가 3을 첫 인자 register에 로드한다.
  2. call Rectangle::square로 점프한다.
  3. 함수 본문이 stack/register에 새 Rectangle { width: 3, height: 3 } 데이터를 만들어 리턴값으로 넘긴다.
  4. 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된 일반 함수로, 인스턴스 메모리와 무관하게 동작한다.