← 문제로

49. 공변 반환 타입과 반환 포인터 조정(covariant return / pointer adjustment)

난이도 중
내 리뷰 · C++
해설 · C++

해설 — 공변 반환 타입과 반환 포인터 조정(covariant return / pointer adjustment)

난이도: 중상

요약

가상 함수를 재정의할 때 반환 타입을 "더 파생된 포인터/참조"로 좁힐 수 있다(공변 반환). 기반 타입(Factory*)으로 호출하면 정적 반환형은 RetBase* 지만 실제 구현은 RetDerived* 를 돌려준다. 반환되는 RetDerived 안에서 RetBase 서브오브젝트가 오프셋 0이 아니면, 컴파일러는 vtable 썽크로 반환 포인터를 그 오프셋만큼 더해 유효한 RetBase* 로 조정한다. 예측 출력: 10 / 16 / true / 20.

핵심 개념

  • 공변 반환(covariant return): Base* f() 를 재정의하며 Derived* f() 로 반환형을 좁히는 것이 허용된다(Derived* 가 Base* 로 암시적 변환 가능하고, 접근·cv 규칙을 만족할 때).
  • 포인터 조정(pointer adjustment): 다중 상속에서 base 서브오브젝트는 파생 객체 내 서로 다른 오프셋에 놓인다. Derived* ↔ Base* 변환은 단순 비트 복사가 아니라 오프셋 가감이다.
  • 썽크(thunk): 가상 디스패치가 RetBase* 를 기대하는데 오버라이더가 RetDerived* 를 반환하므로, vtable 항목이 조정 코드를 거쳐 반환값을 RetBase* 로 바꾼다.

단계별 내부 동작

레이아웃 (Itanium ABI)

struct RetDerived : Pad, RetBase. Pad 는 가상 소멸자/함수를 가져 다형적이므로 primary base 로 선택되어 오프셋 0에 놓이고 vptr 을 공유한다.

  • Pad: vptr(8) + p(8) = 16바이트, 오프셋 0.
  • RetBase: 자체 vptr(8) + x(4) + 패딩 = 16바이트, 오프셋 16.
  • y 는 그 뒤.

그래서 RetDerived 시작 주소에서 RetBase 서브오브젝트까지의 오프셋 = 16.

호출 경로

  • rd = cf.make(): 정적 반환형이 RetDerived*. rd 는 RetDerived 객체(=storage) 시작 주소.
  • rb = f->make(): f 는 Factory*. 가상 디스패치로 ConcreteFactory::make 가 불리고 RetDerived* 를 만들지만, 이 가상 슬롯의 정적 반환형은 RetBase*. 썽크가 RetDerived* 에 +16 을 더해 RetBase 서브오브젝트를 가리키는 RetBase* 로 조정한 값을 반환한다.

각 줄

  • L1 rb->x = 10. rb 는 조정된 뒤 정확히 RetBase 서브오브젝트를 가리키므로 x 접근이 올바르다.
  • L2 (char*)rb - (char*)rd = 16. 조정 오프셋(RetBase 서브오브젝트 오프셋)이 그대로 드러난다.
  • L3 static_cast<RetDerived*>(rb) == rd = true. RetBase* → RetDerived* 다운캐스트는 -16 조정을 수행해 원래 RetDerived 시작 주소로 되돌린다. 두 포인터가 같아진다.
  • L4 rd->y = 20.

흔한 오해·함정

  • "포인터 값은 그대로일 것" 이라는 오해: 다중 상속에서 base/derived 포인터 변환은 값이 바뀐다. reinterpret_cast 로 강제하면 조정이 생략되어 잘못된 base 포인터가 되고 UB. 반드시 static_cast(또는 암시적 변환)를 써야 컴파일러가 오프셋을 가감한다.
  • 공변 반환은 포인터/참조에만: 값 반환(RetBase by value)은 공변 불가(슬라이싱 문제).
  • 오프셋 0이면 조정 없음: 앞 실험처럼 다형적 base 하나만 있으면 그것이 primary base(오프셋 0)라 조정이 0이라 "그냥 되는 것처럼" 보인다. 다중 상속에서 오프셋이 생겨야 썽크가 드러난다.
  • 가상 상속이 섞이면 조정이 vtable 의 vbase 오프셋을 통한 더 복잡한 런타임 계산이 된다.

면접 포인트

  • 공변 반환이 언제 허용되며 값 반환에 안 되는 이유(슬라이싱).
  • 다중 상속에서 Derived*↔Base* 변환이 오프셋 가감이라는 점, static_cast vs reinterpret_cast 차이.
  • primary base 선택 규칙(다형적 base 를 오프셋 0에)과 그로 인해 조정이 보이거나 숨는 이유.
  • 관련: problem31 은 멤버 호출 시 this 포인터 조정, 본 문제는 반환값 포인터 조정(썽크) — 조정 대상이 다르다.