4장 클래스 (Classes)
클래스는 C++에서 코드의 기본 단위로, 광범위하게 사용된다. 클래스 작성 시 따라야 할 주요 권장사항과 피해야 할 점은 다음과 같다.
4.1 생성자에서 작업하기 (Doing Work in Constructors)
생성자에서는 몇 가지 중요한 사항을 주의해야 한다. 특히, 가상 메서드 호출과 에러를 신호할 수 없는 초기화를 피하는 것이 권장된다.
- 가상 메서드 호출 피하기
- 생성자에서 가상 함수가 호출되면, 이 호출이 파생 클래스의 구현으로 디스패치되지 않는다. 현재 클래스가 파생되지 않았다 하더라도, 향후 파생 클래스가 생길 경우 문제가 발생할 수 있어 혼란을 초래할 수 있다.
- 에러 신호의 어려움
- 생성자에서 발생한 오류를 신호하기가 쉽지 않다. 프로그램을 종료하는 방법은 모든 상황에서 적절하지 않으며, 예외 사용은 금지되어 있다. 초기화에 실패하면 객체는 일반적이지 않은 상태에 있게 되어, bool IsValid() 같은 상태 확인 메커니즘이 필요할 수 있다. 하지만 이를 호출하는 것을 잊기 쉽다.
- 생성자에서의 작업 전달 불가
- 생성자의 주소를 참조할 수 없기 때문에 생성자에서 수행한 작업을 다른 쓰레드 등으로 넘기기 어렵다.
- 추천되는 대안
- 생성자에서는 가상 함수를 호출하지 않도록 한다. 에러 처리로 프로그램 종료가 적절하다면 해당 방식을 고려할 수 있다. 그렇지 않다면, TotW #42에서 설명하는 것처럼 Init() 메서드나 팩토리 함수를 사용하는 것이 좋다. 단, 다른 상태가 없는 객체에 Init() 메서드를 추가하는 것은 비추천한다.
4.2 암시적 형 변환 (Implicit Conversions)
암시적 변환은 타입 혼란과 예상치 못한 오류를 초래할 수 있으므로, 암시적 변환을 정의하지 않고 explicit 키워드를 사용하여 불필요한 변환을 방지하는 것이 좋다.
암시적 변환의 동작 방식
암시적 변환이란 한 타입(출발 타입)의 객체가 다른 타입(목적 타입)으로 자동 변환되는 것을 말한다. 예를 들어, int 값을 double 매개변수로 받는 함수에 전달하는 경우가 있다.
C++에서 기본적으로 제공되는 암시적 변환 외에도, 클래스에 특정 멤버를 추가하여 사용자 정의 암시적 변환을 구현할 수 있다.
- 출발 타입의 변환 연산자
- 목적 타입 이름을 사용한 변환 연산자(예: operator bool())를 정의한다.
- 목적 타입의 생성자
- 출발 타입을 유일한 인수로 받는 생성자를 정의한다.
explicit 키워드 사용의 중요성
explicit 키워드는 변환 연산자나 단일 인수 생성자에 적용하여, 명시적인 목적 타입 변환이 필요한 경우에만 사용되도록 강제할 수 있다. 이는 암시적 변환뿐만 아니라 목록 초기화 구문에도 적용된다.
class Foo {
explicit Foo(int x, double y);
...
};
void Func(Foo f);
Func({42, 3.14}); // 오류 발생
위 예시는 언어적 측면에서 암시적 변환이 아니지만, explicit의 관점에서는 이를 암시적 변환으로 취급하여 오류가 발생한다.
암시적 변환의 장단점
- 장점
- 코드에서 타입을 명시할 필요 없이 더 간편하게 사용할 수 있어, 표현력을 높이고 가독성을 높일 수 있다.
- std::string과 const char*를 처리하는 대신 string_view를 사용하는 단일 함수로 오버로딩을 대체하는 경우처럼, 오버로딩보다 간단한 대안이 될 수 있다.
- 목록 초기화 구문은 간결하고 직관적인 객체 초기화 방법이다.
- 단점
- 타입 불일치 오류를 숨길 수 있어, 사용자가 변환이 일어난다는 사실을 인지하지 못할 수 있다.
- 오버로딩이 있는 경우 코드의 실제 호출 내용을 알아보기 어렵게 만들어 가독성을 떨어뜨릴 수 있다.
- 단일 인수를 받는 생성자가 암시적 타입 변환으로 오용될 수 있다.
- 호출 위치에서 암시적 변환이 양방향으로 가능한 경우 모호함을 유발할 수 있으며, 변환 연산자와 생성자가 동시에 존재할 경우에도 마찬가지로 문제가 생긴다.
explicit 사용 원칙
단일 인수를 받는 생성자나 타입 변환 연산자는 클래스 정의에서 반드시 explicit로 선언해야 한다. 예외적으로, 복사 및 이동 생성자는 타입 변환을 수행하지 않으므로 explicit로 선언할 필요가 없다.
예외 상황
암시적 변환이 꼭 필요하고 두 타입이 상호 대체 가능하도록 설계된 경우에는 프로젝트 리더와 협의하여 예외 승인을 요청할 수 있다.
또한, 단일 인수로 호출할 수 없는 생성자나, std::initializer_list 매개변수를 받는 생성자는 복사 초기화를 지원하기 위해 explicit을 생략할 수 있다.
MyType m = {1, 2};
4.3 복사 가능 및 이동 가능 타입 (Copyable and Movable Types)
C++에서는 클래스의 복사 및 이동 가능 여부가 명확하게 드러나도록 해야 한다. 클래스가 복사 가능, 이동 전용, 혹은 둘 다 불가능한지 명확히 선언하는 것이 중요하며, 각 타입의 특성에 맞는 복사 및 이동 기능을 제공해야 한다.
복사 가능 타입과 이동 가능 타입
- 이동 가능 타입
- 임시 객체를 통해 초기화나 할당이 가능한 타입을 말한다. 예를 들어 std::unique_ptr<int>는 이동 가능하지만, 소스 객체를 수정해야 하므로 복사할 수는 없다.
- 복사 가능 타입
- 동일한 타입의 다른 객체를 통해 초기화나 할당이 가능한 타입으로, 복사 이후에도 소스 객체는 변하지 않는다. 예를 들어, int와 std::string은 이동 가능하면서 복사도 가능한 타입이다.
사용자 정의 타입에서의 복사와 이동
- 복사 동작은 복사 생성자와 복사 할당 연산자로 정의된다.
- 이동 동작은 이동 생성자와 이동 할당 연산자로 정의되며, 만약 정의하지 않으면 복사 생성자와 복사 할당 연산자가 대신 사용된다.
복사 및 이동 생성자는 일부 상황에서 컴파일러에 의해 암시적으로 호출될 수 있으며, 객체의 복사나 이동을 통해 간편하게 API를 사용할 수 있도록 한다.
복사 및 이동 생성자를 명시적으로 정의하는 이유
복사 및 이동 생성자는 컴파일러가 자동으로 생성할 수 있어 오류 없이 쉽게 정의할 수 있으며, 보통의 힙 할당이나 별도 초기화 단계를 거치지 않으므로 성능 면에서도 유리하다. 이를 명시적으로 제공하거나 = default로 선언하면 모든 데이터 멤버가 복사되며, 복사 제거와 같은 최적화도 가능해 성능을 높인다.
불필요한 복사 동작 방지
일부 클래스는 복사할 필요가 없다. 예를 들어, 싱글톤 객체나 특정 스코프에 한정된 객체, 객체의 ID와 밀접하게 연결된 객체(예: Mutex)는 복사하면 혼란을 초래하거나 잘못된 결과를 초래할 수 있다. 또한, 다형성을 위한 기본 클래스의 복사는 잘못된 객체 슬라이싱으로 이어질 수 있어 위험하다.
클래스의 복사 및 이동 정책을 명확히 하기
클래스의 퍼블릭 인터페이스에서 지원하는 복사 및 이동 동작을 명확히 나타내야 한다. 예를 들어, 복사 가능한 클래스는 복사 연산자를 명시적으로 선언하고, 이동 전용 클래스는 이동 연산자만 선언하며, 복사 및 이동이 모두 불가능한 클래스는 해당 연산자들을 삭제한다. 복사 또는 이동 할당 연산자를 제공하는 경우, 반드시 대응하는 생성자도 제공해야 한다.
class Copyable {
public:
Copyable(const Copyable& other) = default;
Copyable& operator=(const Copyable& other) = default;
// 암시적으로 이동 연산은 비활성화된다.
// 이동이 필요하다면 명시적으로 선언 가능.
};
class MoveOnly {
public:
MoveOnly(MoveOnly&& other) = default;
MoveOnly& operator=(MoveOnly&& other) = default;
// 복사 연산은 삭제됨.
MoveOnly(const MoveOnly&) = delete;
MoveOnly& operator=(const MoveOnly&) = delete;
};
class NotCopyableOrMovable {
public:
// 복사와 이동 모두 불가능
NotCopyableOrMovable(const NotCopyableOrMovable&) = delete;
NotCopyableOrMovable& operator=(const NotCopyableOrMovable&) = delete;
// 이동 연산도 비활성화됨.
NotCopyableOrMovable(NotCopyableOrMovable&&) = delete;
NotCopyableOrMovable& operator=(NotCopyableOrMovable&&) = delete;
};
필요하지 않은 경우의 명시적 선언 생략
클래스가 struct와 같이 퍼블릭 멤버만으로 구성된 경우, 퍼블릭 데이터 멤버의 복사/이동 가능 여부에 따라 암시적으로 결정되므로 명시적인 선언을 생략할 수 있다. 기본 클래스가 복사나 이동을 명확히 지원하지 않는 경우, 파생 클래스 역시 복사/이동이 불가능하게 된다.
또한, 복사나 이동 연산 중 하나만 명시적으로 선언하거나 삭제하면, 다른 연산 역시 선언 또는 삭제해야 한다.
기본 클래스 슬라이싱 방지
기본 클래스를 추상 클래스로 만들고, 생성자와 소멸자를 protected로 선언하거나 순수 가상 함수를 하나 이상 선언하여 실수로 인한 객체 슬라이싱을 방지하는 것이 좋다.
4.4 Structs vs. Classes
C++에서 struct와 class 키워드는 거의 동일하게 작동하지만, 각 키워드에 의미를 부여해 구분해서 사용하는 것이 중요하다. struct는 주로 데이터를 담는 수동적인 객체에만 사용하며, 그 외의 경우에는 class를 사용하는 것이 좋다.
struct의 사용 목적과 제한
- struct는 데이터만을 보관하는 수동적 객체를 위한 용도로 사용된다.
- 모든 필드는 public으로 설정되어야 하며, 필드 간의 관계를 나타내는 불변 조건이 없어야 한다. 만약 필드 간의 관계를 유지해야 한다면, 사용자가 직접 필드에 접근하면서 이를 위반할 가능성이 생긴다.
- 생성자, 소멸자, 보조 메서드는 사용할 수 있지만, 이러한 메서드가 필드 간의 관계를 강제하거나 불변 조건을 요구하지 않아야 한다.
class가 적합한 경우
struct에서 요구되는 조건을 넘어서는 기능이나 불변 조건이 필요하다면, class가 더 적합하다. 또한, 구조체가 넓은 가시성을 가지며 점진적으로 확장될 가능성이 있다면 class로 선언하는 것이 좋다. 의심스러울 때는 class로 선언하는 것이 안전하다.
STL과의 일관성
STL과 일관성을 유지하기 위해, 상태가 없는 타입(예: traits, 템플릿 메타함수, functor 등)에 대해서는 class 대신 struct를 사용할 수 있다.
멤버 변수의 명명 규칙
struct와 class는 멤버 변수의 명명 규칙이 다르므로, 이를 염두에 두고 일관성 있게 코드를 작성해야 한다.
4.5 Structs vs. Pairs and Tuples
의미 있는 필드 이름을 줄 수 있다면 pair나 tuple보다 struct를 사용하는 것이 좋다.
pair와 tuple은 별도의 타입을 정의하지 않고도 여러 요소를 묶을 수 있는 장점이 있지만, .first, .second 혹은 std::get<X> 방식으로 요소를 접근하는 것은 코드 읽기를 어렵게 만들 수 있다. C++14에서는 std::get<Type>을 통해 타입으로 요소에 접근하는 기능을 제공하지만, 명확한 필드 이름이 가독성 측면에서 훨씬 더 유리하다.
pair와 tuple을 사용할 때 적절한 상황
- 요소들 간에 특별한 의미가 없는 제네릭 코드에서 pair와 tuple은 유용할 수 있다.
- 기존 코드나 API와 호환성을 유지해야 하는 경우에도 pair와 tuple 사용이 필요할 수 있다.
결론적으로, 요소에 의미가 있다면 struct로 정의하여 가독성과 유지 보수성을 높이는 것이 권장된다.
4.6 Inheritance
상속보다는 구성을 사용하는 것이 더 적절한 경우가 많다. 상속을 사용하는 경우, public 상속을 권장하며 상속 구조의 복잡성을 최소화하는 것이 좋다.
상속의 유형
- 인터페이스 상속
- 순수 추상 기본 클래스(상태나 정의된 메서드가 없는 클래스)에서 상속받는 것을 의미하며, 특정 API를 강제할 수 있다. 이 경우 컴파일러가 필요한 메서드가 정의되지 않은 경우 오류를 감지할 수 있다.
- 구현 상속
- 기본 클래스의 코드 재사용을 통해 서브클래스를 구현하는 방식으로, 주로 기존 타입을 확장하는 용도로 사용한다. 컴파일 시점에 상속이 선언되므로 컴파일러와 개발자가 구조를 이해하고 오류를 감지할 수 있지만, 기본 클래스와 서브클래스 간의 코드가 분산되므로 구조를 파악하기 어려울 수 있다.
상속의 주의사항
- 가상 함수
- 서브클래스는 가상 함수가 아닌 함수는 오버라이딩할 수 없다. 따라서 서브클래스에서 기본 클래스의 구현을 변경할 수 있는 유연성이 제한된다.
- 다중 상속의 문제
- 다중 상속은 특히 성능 저하와 혼란을 초래하는 다이아몬드 상속 문제를 야기할 수 있다. 이러한 패턴은 모호성, 복잡성, 버그를 유발하기 쉬우므로 다중 구현 상속은 강력히 지양해야 한다.
접근 제어 및 키워드 사용
- Public 상속 권장
- 상속은 가능하면 public으로 선언하고, private 상속이 필요하다면 구성을 통해 해결하는 것이 좋다.
- Protected 사용 제한
- 서브클래스에서 접근해야 할 필요가 있는 함수에 한정해서 사용하고, 데이터 멤버는 private으로 설정해야 한다.
- Override와 Final
- 가상 함수나 가상 소멸자를 오버라이드할 때는 override나 final을 명시해야 하며, virtual을 재선언하지 않는다. 이로써, 해당 함수가 기본 클래스의 가상 함수를 오버라이드하지 않는 경우 컴파일 시점에 오류를 감지할 수 있다.
상속의 적절한 사용 시나리오
- 상속은 "is-a" 관계일 때 사용하며, Bar가 "Foo의 일종"으로 합리적으로 설명될 수 있을 때 상속을 사용하는 것이 적합하다.
- Composition: 상속보다는 구성을 선호하는 것이 좋으며, 특히 기능이 복잡해질 경우 구성 방식이 유지보수와 이해 측면에서 유리하다.
다중 상속
다중 상속은 인터페이스 상속에서는 허용되지만, 다중 구현 상속은 강력히 지양해야 한다.
4.7 연산자 오버로딩 (Operator Overloading)
연산자 오버로딩은 주의 깊게 사용해야 하며, 의미가 명확하고 예상 가능한 경우에만 정의하는 것이 좋다. 특히, 사용자 정의 리터럴은 혼란을 초래할 수 있으므로 사용하지 않는다.
연산자 오버로딩의 장점과 주의사항
C++에서는 operator 키워드를 사용해 내장 연산자를 오버로딩할 수 있다. 이 기능은 사용자 정의 타입이 내장 타입처럼 동작할 수 있게 해 코드가 간결해지고 직관적이 될 수 있다. 하지만 연산자 오버로딩은 기대에 부합하지 않으면 혼란과 오류를 유발할 수 있으며, 과도하게 사용하면 코드가 난해해질 위험이 있다. 특히, 다음과 같은 주의사항이 있다.
- 암시적 비용 문제
- 사용자 정의 연산자가 복잡한 작업을 수행할 때, 내장 연산자처럼 보일 수 있어 성능을 직관적으로 이해하기 어렵게 할 수 있다.
- 찾기 어려운 호출 위치
- 연산자 오버로딩은 일반적인 함수 호출보다 호출 위치를 찾기 어렵다. 특히 잘못된 인수 타입으로 인해 다른 오버로드가 호출될 수 있다.
- 위험한 연산자 오버로딩
- &&, ||, ,(콤마) 및 단항 & 연산자는 오버로딩을 지양해야 하며, 이러한 연산자는 내장 연산자의 평가 순서와 일치하지 않을 수 있다.
- 파일 간의 중복 정의 위험
- 연산자를 클래스 외부에 정의할 때, 동일 연산자가 다른 파일에 중복 정의될 위험이 있다. 이 경우 링크 시 오류 없이 미묘한 런타임 버그가 발생할 수 있다.
사용자 정의 리터럴(UDF) 지양
사용자 정의 리터럴은 코드의 의미를 명확히 하지 못하고, using 구문을 요구하는 경우가 많아 혼란을 줄 수 있다. C++ 표준 라이브러리에서 제공하는 사용자 정의 리터럴도 사용하지 않는 것이 좋다.
연산자 오버로딩 규칙
- 자체 정의 타입에만 오버로딩
- 오버로딩은 해당 타입과 동일한 헤더 파일, 소스 파일, 네임스페이스 내에서 정의해야 한다. 가능하다면 템플릿을 사용하지 않는 것이 좋다. 템플릿 연산자는 모든 템플릿 인수에 대해 규칙을 만족시켜야 하기 때문이다.
- 일관된 관련 연산자 제공
- ==를 정의할 경우, 관련 연산자도 함께 정의해 일관성을 유지한다. 예를 들어, operator<=>를 정의할 때는 operator==과 일관되게 정의해야 한다.
- 비변경 이항 연산자는 비멤버 함수로
- 비변경 이항 연산자를 클래스 멤버로 정의하면 암시적 변환이 오른쪽 인수에만 적용되므로 혼란을 줄 수 있다. 예를 들어, a + b는 컴파일되지만 b + a는 오류가 날 수 있다.
- 기본 제공 타입과의 일관성 유지
- 연산자 의미가 내장 타입과 동일하게 명확하고 일관적일 때만 오버로딩을 고려한다. 예를 들어, |는 비트 연산이나 논리 OR로 사용하는 것이 좋으며, 쉘 스타일 파이프처럼 사용하지 않는다.
- 필요한 연산자만 정의
- ==, = 및 <<와 같은 유용한 연산자는 오버로딩하되, 불필요한 오버로딩은 지양한다. 자연스러운 순서가 없는 타입의 경우, <를 오버로딩하는 대신 std::set에 사용할 비교자를 제공하는 편이 낫다.
- 금지된 오버로딩
- &&, ||, ,(콤마)와 단항 &, operator""는 오버로딩하지 않는다.
연산자 오버로딩의 기타 참고사항
- 형변환 연산자는 암시적 변환 규칙에 포함되어 있으며, = 연산자는 복사 생성자와 관련된다.
- 스트림 연산자 << 오버로딩은 별도의 스트림 관련 규칙을 참조하는 것이 좋다.
연산자 오버로딩은 코드 가독성과 일관성에 큰 영향을 미치므로, 일관성 있고 명확한 경우에만 사용하며, 과도한 오버로딩은 피하는 것이 좋다.
4.8 접근 제어 (Access Control)
클래스의 데이터 멤버는 기본적으로 private으로 설정하여 불변 조건(invariant)을 유지하는 것이 좋다. 이는 코드의 가독성과 유지보수성을 높이며, 필요한 경우 읽기 전용 접근자(const) 등을 통해 접근할 수 있다. 상수 데이터 멤버는 public으로 설정할 수 있다.
테스트 코드에서의 예외
일부 테스트 코드 작성 시 protected 접근을 허용하는 경우가 있다. 예를 들어 Google Test를 사용할 때, .cc 파일 내에서 정의된 테스트 픽스처 클래스의 멤버는 protected로 설정할 수 있다. 하지만 .cc 파일 외부, 예를 들어 .h 파일에 정의된 테스트 픽스처 클래스에서는 데이터 멤버를 private으로 설정하는 것이 좋다.
결론적으로, 클래스의 캡슐화를 강화하기 위해 private 접근을 기본으로 하고, 테스트 목적으로 필요한 경우에만 protected 접근을 고려한다.
4.9 선언 순서 (Declaration Order)
클래스 정의에서는 유사한 선언을 묶고, public 멤버를 먼저 정의하는 것이 좋다. 기본적으로 클래스 정의는 public:부터 시작하며, 그 뒤에 protected:, 마지막으로 private: 섹션을 두는 순서를 권장한다. 각 섹션에서 필요 없는 부분은 생략한다.
선언 순서 권장 사항
각 접근 제어 섹션 내부에서는 비슷한 유형의 선언을 다음 순서로 그룹화하는 것이 좋다.
- 타입과 타입 별칭: typedef, using, enum, 중첩된 struct나 class, friend 타입
- (구조체의 경우 선택적) 비정적 데이터 멤버
- 정적 상수
- 팩토리 함수
- 생성자와 할당 연산자
- 소멸자
- 기타 함수: 정적 및 비정적 멤버 함수, friend 함수
- 기타 데이터 멤버: 정적 및 비정적 데이터 멤버
class MyClass {
public:
// 1. 타입과 타입 별칭: typedef, using, enum, 중첩된 struct나 class, friend 타입
using StringList = std::vector<std::string>;
enum Status { OK, ERROR };
struct NestedStruct {
int id;
double value;
};
// 2. 정적 상수
static const int MaxSize = 100;
// 3. 팩토리 함수
static MyClass Create();
// 4. 생성자와 할당 연산자
MyClass();
MyClass(const MyClass& other);
MyClass& operator=(const MyClass& other);
MyClass(MyClass&& other) noexcept;
MyClass& operator=(MyClass&& other) noexcept;
// 5. 소멸자
~MyClass();
// 6. 기타 함수 (정적 및 비정적 멤버 함수, friend 함수)
void DoSomething() const;
static void StaticFunction();
friend void ExternalFunction(MyClass& mc);
protected:
// 7. (구조체의 경우 선택적) 비정적 데이터 멤버
std::string name_;
private:
// 8. 기타 데이터 멤버 (정적 및 비정적 데이터 멤버)
int data_;
static int shared_count_;
};
클래스 정의 내 인라인 함수 지양
클래스 정의 내에서 큰 메서드를 인라인으로 정의하지 않도록 한다. 일반적으로 간단한 메서드나 성능이 중요한 매우 짧은 메서드만 인라인으로 정의하는 것이 좋다. 인라인 함수의 추가 정보는 관련된 규칙을 참조한다.
이와 같은 선언 순서를 유지함으로써 클래스 구조가 일관되고, 가독성이 높아진다.
참조URL
'기술 노트 > Google C++ Style Guide' 카테고리의 다른 글
Google C++ Style Guide(2024) - 6장 Google 특유의 테크닉(Google-Specific Magic) (2) | 2024.11.09 |
---|---|
Google C++ Style Guide(2024) - 5장 함수 (Functions) (0) | 2024.11.08 |
Google C++ Style Guide(2024) - 3장 스코핑 (Scoping) (0) | 2024.11.06 |
Google C++ Style Guide(2024) - 2장 헤더 파일 (Header File) (0) | 2024.11.05 |
Google C++ Style Guide(2024) - 1장 C++ 버전 (C++ Version) (0) | 2024.11.04 |