HOWTO · C++
C++의 클래스 유형 재정의
이 문서에서는 C++에서 클래스 유형 재정의를 해결하는 방법에 대해 설명합니다.
이 페이지의 내용
C++ 클래스 정의는 이름에 메모리 구조와 멤버 선언을 부여합니다. 같은 범위에서 해당 클래스를 다시 정의하면 올바르지 않으므로 컴파일러가 클래스 재정의 오류를 보고합니다. 해결하려면 중복 정의를 찾아 제거하거나 하나로 합쳐야 합니다. 상속과 컴포지션은 별개의 타입에 동작을 추가하는 데 도움이 되지만, 원래 클래스를 두 번 정의할 수 있게 해 주지는 않습니다.
C++ 클래스 재정의 오류 이해하기
클래스 정의는 타입을 선언합니다. 하나의 범위 안에서는 타입 이름을 클래스로 두 번 정의할 수 없습니다. 두 정의의 멤버가 같더라도 마찬가지입니다. 예를 들어 같은 번역 단위에 class DelftStack { }; 정의가 두 개 있으면 redefinition of 'class DelftStack'와 같은 진단이 발생합니다. 정확한 문구와 소스 위치는 컴파일러마다 다릅니다.
이는 멤버를 정의하지 않고 타입을 알리는 class DelftStack; 같은 전방 선언과 다릅니다. 전방 선언 뒤에 전체 정의를 추가하는 것은 허용됩니다. 이 오류는 클래스 이름이 반복해서 언급되는 경우가 아니라 정의가 반복되는 경우에 발생합니다.
포함된 헤더를 비롯해 중복 정의 찾기
진단에 나온 클래스 이름을 확인하고, 컴파일러가 표시한 줄과 앞선 위치를 살펴보세요. 현재 소스 파일에서 다른 정의가 있는지 검색합니다. 그런 다음 #include 지시문을 따라가 보세요. 헤더가 같은 클래스를 정의했거나, 포함된 헤더 두 개가 각각 같은 이름의 클래스를 정의했을 수 있습니다.
헤더를 여러 번 포함하면 include guard나 #pragma once를 사용하지 않은 경우 해당 내용이 하나의 번역 단위에서 반복될 수 있습니다. 일반적인 include guard는 이식성 있는 전처리기 기법입니다. #pragma once는 널리 지원되는 컴파일러 지시문이지만 ISO C++ 표준의 일부는 아닙니다. 두 방법 모두 같은 헤더가 반복해서 포함되는 것을 막지만, 서로 다른 헤더에서 같은 클래스를 각각 정의하는 문제까지 해결하지는 않습니다. 소스 파일이 정의가 들어 있는 헤더를 포함하면서 클래스 본문도 직접 다시 작성했는지 확인하세요.
정의를 하나만 남겨 오류 수정하기
기준이 될 정의 하나를 선택하고 실수로 생긴 복사본을 제거하세요. 두 복사본에 필요한 멤버가 모두 있다면 하나의 정의에 합친 뒤, 필요에 따라 호출하는 쪽도 수정합니다. 헤더에 정의를 두었다면 해당 헤더를 포함하고 소스 파일에 클래스 본문을 복사하지 마세요. 같은 헤더가 반복 처리되지 않도록 일반적인 include guard를 추가하거나 널리 지원되는 #pragma once 지시문을 사용할 수 있습니다.
클래스 이름을 바꾸는 것은 실제로 서로 다른 역할을 하는 별개의 타입일 때만 적절합니다. 실수로 중복된 정의의 이름만 바꾸면 근본적인 설계 또는 헤더 포함 문제가 가려지고 서로 경쟁하는 타입 두 개가 남을 수 있습니다. 이름을 바꾸기 전에 프로그램에 두 타입이 정말 필요한지 확인하고, 사용처도 일관되게 수정하세요.
상속으로 기존 클래스 확장하기
새 타입이 기반 클래스의 하위 타입이며 기반 클래스의 공개 인터페이스로도 사용할 수 있어야 할 때는 공개 상속을 사용하세요. 파생 클래스는 멤버와 함수를 추가할 수 있지만, 기반 클래스를 다시 정의하지는 않습니다. 공개 상속에서는 기반 클래스의 공개 멤버가 공개 상태로 유지되고 보호 멤버는 파생 클래스 코드에서 계속 사용할 수 있습니다. 비공개 기반 멤버는 파생 클래스에서 직접 접근할 수 없습니다. 비공개 상속과 보호 상속은 기반 클래스 멤버가 파생 타입의 사용자에게 노출되는 방식을 바꿉니다.
C++ 컴파일러로 다음 독립 실행형 예제를 실행해 보세요:
이 섹션의 코드와 표시되는 레이블 또는 출력은 영어로 유지됩니다.
#include <iostream>
class Greeter {
public:
void greet() const { std::cout << "Hello from DelftStack!\n"; }
};
class FriendlyGreeter : public Greeter {
public:
void greet_twice() const {
greet();
greet();
}
};
int main() {
FriendlyGreeter greeter;
greeter.greet_twice();
}
Hello from DelftStack!
Hello from DelftStack!
이 프로그램은 인사말을 두 번 출력합니다. FriendlyGreeter는 특화된 인사말 클래스이며 상속받은 공개 함수를 재사용합니다. 실제로 하위 타입 관계가 성립하지 않는다면 상속으로 인해 인터페이스가 오해를 불러일으킬 수 있습니다.
컴포지션으로 동작 추가하기
컴포지션은 ‘가지고 있다(has-a)’ 관계를 표현합니다. 한 클래스가 다른 클래스의 객체를 저장하고 그 객체에 작업을 위임합니다. 이렇게 하면 포함된 타입의 인터페이스를 별도로 유지하면서 바깥 클래스가 어떤 연산을 공개할지 선택할 수 있습니다. 여기서 Report는 Formatter 멤버를 저장하고 렌더링을 그 멤버에 위임합니다. 보고서는 포매터를 가지고 있지만 포매터 자체는 아닙니다.
이 섹션의 코드와 표시되는 레이블 또는 출력은 영어로 유지됩니다.
#include <string>
class Formatter {
public:
std::string format(const std::string& text) const {
return "Report: " + text;
}
};
class Report {
public:
std::string render(const std::string& text) const {
return formatter_.format(text);
}
private:
Formatter formatter_;
};
int main() {
Report report;
return report.render("Summary") == "Report: Summary" ? 0 : 1;
}
컴포지션에서도 클래스 정의는 각각 고유해야 합니다. 멤버 객체의 클래스가 헤더에 정의되어 있다면 그 헤더를 포함하고 바깥 클래스를 한 번만 정의하세요. 멤버 객체를 추가해도 같은 범위에 있는 다른 중복 정의가 해결되지는 않습니다.
변경에 상속과 컴포지션 중 선택하기
새 타입이 기반 타입의 계약을 만족하고 기반 타입이 필요한 모든 곳에서 안전하게 사용될 수 있어야 한다면 상속을 선택하세요. 새 타입이 다른 객체를 구성 요소로 소유하거나 사용하거나, 그 객체 동작 중 일부만 공개해야 한다면 컴포지션을 선택하세요. 컴포지션은 의존성과 공개 인터페이스를 서로 독립적으로 유지하는 데 도움이 되는 경우가 많고, 대체 가능성이 의도된 경우에는 상속이 유용합니다.
어느 방법도 실수로 생긴 클래스 중복 정의를 바로잡지는 못합니다. 먼저 각 타입에 유효한 정의를 하나씩 마련하세요. 그런 다음 원하는 설계 관계에 맞는 경우에만 상속이나 컴포지션을 사용하세요.
컴파일러로 수정 결과 확인하기
중복을 제거하거나 하나로 합친 뒤 영향을 받는 번역 단위와 관련 프로젝트 대상을 컴파일하세요. main.cpp라는 단일 소스 파일을 컴파일할 때는 보통 g++ -std=c++17 main.cpp -o app 명령을 사용하며, 생성된 프로그램은 ./app으로 실행합니다. 앞의 상속 예제는 컴파일하고 실행했으며 상태 코드 0으로 종료되고 위에 표시된 두 줄의 인사말을 출력했습니다. 중복 정의 진단은 설명을 위한 예시입니다. 제공된 실행 증거는 상속 예제에 대한 것이며 진단 예제를 실행한 결과는 아닙니다.
진단이 계속되면 컴파일러가 표시한 두 위치를 확인하고 포함된 헤더를 다시 추적하세요. 특정 빌드 구성에서만 중복이 나타난다면 include 경로와 전처리기 정의를 비교합니다. 진단 문구와 줄 번호는 컴파일러와 빌드에 따라 달라지므로 공유할 때 진단 텍스트를 선택할 수 있는 상태로 유지하세요.