23. 이름 숨김(name hiding)과 오버로드 해석의 스코프 규칙
난이도 최상해설 — 이름 숨김(name hiding)과 오버로드 해석의 스코프 규칙
난이도: 최상
답변 프레임워크: 요약 → 핵심 개념 → 단계별 내부 동작 → 흔한 오해·함정 → 면접 포인트
요약
LoginHandler 가 Handle(int) 를 새로 선언하는 순간, 기반 클래스 PacketHandlerBase
의 Handle 이라는 이름을 가진 오버로드 전체(세 개 모두)가 파생 클래스 스코프에서 가려진다.
C++ 의 이름 조회(name lookup)는 "가장 안쪽 스코프에서 그 이름을 찾으면 즉시 멈추고, 그
스코프 안에서만 오버로드 해석을 수행"하기 때문이다 — 인자 타입이 더 잘 맞는 함수가 바깥
스코프(기반 클래스)에 있어도 조회 자체가 거기까지 가지 않는다. 그래서 lh.Handle("ping")
은 Base::Handle(const char*) 가 있음에도 컴파일 에러(LoginHandler::Handle(int) 에
const char* 를 넣으려다 실패)가 난다. 반면 base->Handle(42) 처럼 기반 클래스 포인터로
호출하면 이름 조회가 PacketHandlerBase 스코프에서 이뤄지므로 숨김의 영향을 받지 않고,
Handle(int) 는 비가상이라 정적 타입 기준으로 Base::Handle(int) 가 호출된다(동적
타입은 무시). Handle(double) 은 가상이고 파생 클래스가 정확히 같은 시그니처로 오버라이드
했으므로, 정적/동적 타입 어느 경로로 불러도 vtable을 통해 LoginHandler::Handle(double)
이 호출된다.
출력 예측
LoginHandler::Handle(int) code=42
LoginHandler::Handle(double) value=3.1
Base::Handle(int) code=42
LoginHandler::Handle(double) value=3.1
그리고 주석 처리된 lh.Handle("ping"); 의 주석을 풀면:
error: invalid conversion from 'const char*' to 'int'
note: initializing argument 1 of 'void LoginHandler::Handle(int)'
— 컴파일 자체가 실패한다. Base::Handle(const char*) 가 더 정확히 맞는 오버로드임에도
후보에 오르지 못한다.
핵심 개념
- 이름 조회는 2단계: (1) 스코프에서 이름 찾기 → (2) 그 스코프 안에서 오버로드 해석.
파생 클래스 스코프에
Handle이라는 이름이 하나라도 선언돼 있으면, 조회는 거기서 멈춘다. 기반 클래스 스코프까지 올라가서 "더 잘 맞는 오버로드가 있는지" 찾지 않는다. - 이름 숨김(name hiding)은 오버로딩이 아니라 스코프 규칙. 파생 클래스의
Handle(int)은 기반의Handle(int)를 "오버로드"하는 게 아니라, 기반 클래스 스코프에 있는Handle이라는 이름 전체를 가린다(hide). 시그니처가 다르든 같든 상관없이 이름 단위로 가려진다. using Base::Handle;로 끌어오기(using-declaration). 파생 클래스 안에using PacketHandlerBase::Handle;을 선언하면, 기반의Handle오버로드 세트가 파생 클래스 스코프에 함께 도입되어 파생 자신의Handle(int)와 한 오버로드 집합을 이룬다. 이렇게 하면lh.Handle("ping")도 정상적으로Base::Handle(const char*)를 찾는다.- 비가상 함수의 "숨김"과 가상 함수의 "오버라이드"는 다른 메커니즘.
Handle(double)처럼 시그니처가 정확히 일치하고virtual인 경우엔 오버라이드가 되어 vtable 슬롯을 교체한다 (동적 디스패치).Handle(int)처럼 파생 쪽이virtual을 안 붙였어도 시그니처가 같으면 여전히 오버라이드가 성립하지만(암묵적으로 virtual 유지), 이 문제의 핵심은 오버라이드 여부가 아니라 이름 조회 단계에서 나머지 오버로드들이 안 보이게 된다는 스코프 문제다.
단계별 내부 동작
- 컴파일러가
lh.Handle(42)를 컴파일할 때,lh의 정적 타입LoginHandler스코프에서Handle이름을 찾는다.LoginHandler::Handle(int)가 발견되는 즉시 조회를 멈춘다 (기반 클래스 스코프는 아예 보지 않는다). - 그 스코프(
LoginHandler) 안에서만 오버로드 해석을 수행: 후보가Handle(int)하나뿐이라 바로 선택된다. 비가상이므로 정적 바인딩으로LoginHandler::Handle(int)호출. lh.Handle(3.14):LoginHandler스코프에서Handle(double)이 발견돼 조회가 멈춘다. 가상 함수이므로 vtable 조회를 거치지만, 동적 타입도LoginHandler라 결과는 동일하게LoginHandler::Handle(double).lh.Handle("ping")(주석 해제 시):LoginHandler스코프에서Handle을 찾고 멈추므로 후보는Handle(int)뿐.const char*→int암묵적 변환이 불가능해(포인터→정수는 금지) 오버로드 해석 실패 → 컴파일 에러.base->Handle(42):base의 정적 타입은PacketHandlerBase*이므로 이름 조회가PacketHandlerBase스코프에서 이뤄진다(파생 클래스의 숨김 규칙이 적용될 여지가 없다 — 애초에 그 스코프를 보지 않으므로).Handle(int)는 비가상이라 정적 타입 기준으로Base::Handle(int)를 직접 호출(동적 타입LoginHandler는 무시).base->Handle(3.14):Handle(double)은 가상이므로, 정적 타입에서 오버로드 해석으로 슬롯을 찾은 뒤 vtable 을 통해 동적 타입(LoginHandler)의 오버라이드로 디스패치된다 →LoginHandler::Handle(double).
흔한 오해·함정
- "파생 클래스가 오버로드 하나만 새로 선언하면 나머지 오버로드는 자동으로 상속된다" ✗ — 가장 흔한 오해. 이름이 같으면 시그니처와 무관하게 기반의 오버로드 세트 전체가 숨겨진다. 이는 컴파일 에러가 아니라 "호출 시 원치 않는 함수가 선택되거나 컴파일 실패"로 나타나서 발견이 늦어지기 쉽다.
- "이름 숨김은 가상 함수에만 적용된다" ✗ — 가상 여부와 무관하게 스코프/이름 조회 규칙
이다. 비가상 함수도 똑같이 숨긴다. 이 문제의
Handle(int)가 바로 그 예다. 다만 시그니처가 정확히 일치하는 가상 함수는 "숨김"이 아니라 "오버라이드"로 취급된다는 차이는 있다(오버라이드된 함수도 여전히 그 이름으로 호출 가능하다는 점에서 결과적으로 드러나 보이지 않을 뿐). - "
using Base::Handle;은 그냥 편의 문법이다" ✗ — 단순 문법 설탕이 아니라, 이름 조회가 참조하는 오버로드 후보 집합 자체를 바꾸는 선언이다. 이걸 빠뜨리면 기반 클래스의 의도된 오버로드들이 파생 클래스 사용자에게 "없는 것처럼" 보인다. - "경고라도 뜨겠지" ✗ — 일부 컴파일러는
-Woverloaded-virtual로 가상 함수 관련 숨김만 경고할 뿐, 비가상 오버로드 숨김은 기본적으로 아무 경고 없이 조용히 통과한다. 실무에서는 파생 클래스가 기반의 오버로드 세트를 일부만 재정의할 계획이면 반드시using Base::함수명;을 습관적으로 추가해야 한다.
면접 포인트 (예상 질문)
- 파생 클래스에서 기반 클래스와 이름이 같은 함수를 선언하면 왜 기반의 다른 오버로드까지 전부 가려지는가? 이것이 "오버로드 해석 실패"가 아니라 "이름 조회" 문제인 이유는?
using Base::Handle;을 추가하면 내부적으로 무슨 일이 일어나는가?base->Handle(42)와lh.Handle(42)의 결과가 다른 이유는? (정적 타입에 따른 이름 조회 스코프 차이 + 비가상 함수의 정적 바인딩)- 이름 숨김 규칙이 가상 함수 오버라이드와 상호작용할 때 어떤 차이가 생기는가? (시그니처 완전 일치 + virtual → 오버라이드로 vtable 교체, 그 외 → 단순 숨김)