Google C++ Style Guide
3. Classes
A. Doing Work in Constructors (생성자의 역할)
일반적으로, 생성자는 멤버 변수를 초기화 해야 한다. 초기화가 복잡하면 Init() 함수를 사용한다.
정의 :
생성자의 구현부에 초기화를 수행한다.
장점 :
타이핑이 편리하다. 클래스가 초기화 되었는지 걱정할 필요가 없다.
단점 :
아래와 같은 문제가 있다.
- 생성자에서 예외를 사용하는 에러 처리가 어렵다. (예외가 발생하면 안된다)
- 초기화 작업에 실패가 발생하면 객체는 생성되지 않으며 불명확한 상태가 된다.
- 만약 가상 함수를 콜한다면, 서브 클래스로 전파되지 않는다.
- 만약 누군가 전역 변수를 만든다면, 생성자는 main() 이전에 호출될 것이며, 생성자 코드내에서 암시적으로 가정한 것들이 꼬여버릴 수 있다.
결론 :
객체가 중요한 초기화를 수행한다면 명시적인 Init() 메소드를 사용한다. 특히 생성자는 가상 함수를 호출하지 않는다. 잠재적으로 초기화되지 않은 전역 변수를 사용하는 에러가 발생할 것이다.
B. Default Constructors (기본 생성자)
클래스가 멤버 변수를 정의하고 다른 생성자가 없다면 기본 생성자를 정의한다. 그렇지 않으면 컴파일러가 ‘나쁜’ 기본 생성자를 만들어 낼 것이다.
정의 :
기본 생성자는 인자가 없는 객체를 새로(new) 만들 때 호출된다. 배열의 new[]도 마찬가지다.
장점 :
디폴트로서 ‘불가능한’ 값으로 초기화하는 것은 디버깅을 쉽게 만든다. ???
(변수가 갖을 수 없는 값(0, NULL, 등)으로 초기화 한다는 의미인 것 같음)
단점 :
코드 작성자가 추가 작업이 필요하다는 것 뿐이다.
결론 :
클래스가 멤버 변수를 정의하고 다른 생성자가 없다면, .(파라미터가 없는) 기본 생성자를 정의한다. 되도록 내부 상태를 일관되고 유효한 방법으로 객체를 초기화 한다.
생성자가 없으면 컴파일러가 생성자를 만들지만, 클래스 내부 객체를 ‘센스있게’ 초기화 해주진 않을 것이다.
다만, 다른 클래슬 상속받았지만 새로운 멤버 변수가 없다면 기본 생성자가 없어도 된다.
C. Explicit Constructors (명시 생성자)
하나의 인자를 갖는 생성자를 위해 C++ 키워드 explicit 를 사용한다.
정의 :
보통 생성자가 하나의 인자를 갖는다면 암시적 변환이 되어 사용될 수 있다. 예를 들어 Foo::Foo (string name)를 정의하고, Foo 객체를 받는 파라미터를 가진 함수에 string 값을 넘긴다면, 생성자는 string을 Foo로 변환할 것이고, 그 함수에 Foo 객체를 넘길 것이다. 이는 편리하지만 의도하지 않은 결과를 낳을 수 있다. 이런 암시적 변환을 막기 위해 생성자를 explicit로 선언한다.
장점 :
의도하지 않는 변환을 피할 수 있다.
단점 :
없다.
결론 :
모든 하나의 인자를 갖는 생성자는 항상 explicit로 선언한다. 예, explicit Foo(string name);
복사 생성자는 예외이다. 복사 생성자가 암시적 변환을 허용할 때가 가끔 있으며 explicit를 사용하지 않는다. 다른 클래스를 감싸는 투명한 wrapper 클래스로 의도된 클래스도 예외이다. 그런 경우는 확실히 주석으로 표시한다.
필요하면 복사 생성자와 대입 연산자(operator=) 를 제공한다.
그렇지 않으면 DISALLOW_COPY_AND_ASSIGN을 사용하여 비활성화 한다. (아래)
정의 :
복사 생성자와 대입 연산자는 객체를 복사한다. 복사 생성자는 경우에 따라 컴파일러가 암시적으로 호출한다 (예, 값에 의한 객체 전달)
장점 :
복사 생성자를 사용하면 객체 복사가 쉽다. STL 컨테이너는 모든 컨텐츠가 복사가능하고 대입 가능해야 한다. 복사 생성자는 CopyFrom()와 같은 함수보다 더 효율적이다. 생성자를 복사와 결합시키고, 컴파일러는 어떤 문맥은 무시할 수도 있으며, heap allocation을 피하기 쉽기 때문이다.
단점 :
C++에서 암시적인 객체의 복사는 많은 버그와 성능 저하 문제를 발생시킨다. 또한 가독성을 줄이며, 어떤 객체가 참조가 아닌 값에 의한 전달을 하는지, 그래서 객체에 대한 변화가 어디서 반영이 되는지를 추적하기 어렵다.
결론 :
대부분의 클래스가 복사 가능할 필요는 없다. 그래서 대부분 복사 생성자와 대입 연사자를 갖지 않는다. 많은 경우 포인터나 참조자는 값을 복사하는 것처럼 잘 동작하며 성능은 더 좋다. 예를 들어 함수 파라미터에 값이 아닌 포인터나 참조자로 넘기면 STL 컨테이너의 객체를 저장하는 대신 포인터를 저장할 수 있다.
클래스가 복사될 필요가 있다면 복사 생성자보다 CopyFrom()이나 Clone()와 같은 복사 함수를 제공하는 것이 더 좋다. 그런 함수들은 암시적 변환이 가능하지 않기 때문이다. 복사 함수를 사용하기 충분한 상황이 아니라면, 복사 생성자와 대입 연사자를 모두 제공한다.
복사 생성자나 대입 연산자가 필요 없으면 반드시 명시적으로 disable 시킨다. 즉, private 선언 내에 복사 생성자와 대입 연산자를 위한 dummy 선언을 한다.
다음과 같이 매크로를 사용할 수 있다.
// A macro to disallow the copy constructor and operator= functions // This should be used in the private: declarations for a class #define DISALLOW_COPY_AND_ASSIGN(TypeName) \ TypeName(const TypeName&); \ void operator=(const TypeName&) |
class Foo |
class Foo { public: Foo(int f); ~Foo();
private: DISALLOW_COPY_AND_ASSIGN(Foo); }; |
E. Structs vs. Classes (구조체 vs 클래스)
단지 데이터를 전달하는 수동적인 객체에 대해서 struct(구조체)를 사용한다. 그 외에는 class를 사용한다.
struct와 class 키워드는 C++에서 거의 동일하게 동작한다. 그래서 각 키워드에 대해 의미를 부여하고, 정의하고자 하는 데이터 타입에 대해 적절한 키워드를 사용 해야 한다.
struct는 데이터를 나르는 수동적인 객체에 대해 사용하며 연관된 상수를 갖을 수 있지만 데이터 멤버에 접근해서 값을 세팅하는 등의 함수 기능은 부족하다. 그것 보다는 직접 변수에 액세스해서 사용하며, 함수를 사용할 경우는 행동을 제공하는 형태가 아니라 생성자/소멸자/Initialize()/Reset()/Validate() 와 같은 멤버 변수를 세팅하는데 사용되어야 한다.
더 많은 기능이 필요하다면 class를 사용한다. 고민이 되면 class를 사용한다.
STL의 일관성을 위해 functor과 trait를 위해 class대신 struct를 사용한다.
struct와 class에서 멤버 변수는 다른 네이밍 규칙을 따른다는 것에 유의한다.
Composition(배치, 구성, 컴포지션)(멤버 변수로 선언)이 종종 상속보다 더 적절하다. 상속을 사용할 경우 public를 사용한다.
정의 :
Sub-class가 base class를 상속할 때 부모 클래스가 정의한 데이터와 연산을 모두 포함한다. 실제로 상속은 C++에서 두가지 방법으로 사용된다: ‘구현 상속’ 과 ‘인터페이스 상속’
장점 :
구현 상속(implementation iniheritance)은 base class의 코드를 재사용함으로써 코드 사이즈를 줄여준다. 상속은 컴파일 타임 선언이기 때문에 컴파일 타임에 연산을 이해하고 에러를 검출할 수 있다.
인터페이스 상속은 클래스가 특별한 API를 프로그래밍적으로 노출시킬 때 사용한다. 서브 클래스가 필수적인 API를 정의하지 않으면 에러를 낸다.
단점 :
구현 상속인 경우 sub-class의 코드 구현 부분은 base 클래스와 sub-class에 모두 걸쳐있기 때문에 이해하기 어려울 수 있다. sub-class는 virtual(가상)이 아닌 함수는 오버라이드할 수 없어서, 구현을 바꿀 수 없다. base class에서 정의하는 일부 데이터 멤버는 물리적인 레이아웃을 명시할지도 모른다.
결론 :
모든 상속은 public으로 한다. private 상속을 하려면, 상속 대신 base class의 객체를 멤버 변수로 사용한다.
구현 상속을 남용하지 않는다. Composition이 때론 더 적당하다. ‘is-a’의 관계에 있는지 검토한다. Bar가 Foo의 한 종류(‘is a kind of’)라면 Bar는 Foo의 서브 클래스이다.
필요하면 소멸자를 가상(virtual)로 만든다. 가상 함수가 존재한다면, 소멸자는 가상 함수 이어야 한다.
서브 클래스에서 액세스되는 멤버 함수에 protected 사용을 자제한다. Data members shold be private 항목을 참고한다.
상속받은 가상 함수를 재정의 할 때, 서브 클래스에서 명시적으로 virtual이라고 선언한다. 이론적으로 virtual이 생략되었다면, 해당 함수가 가상인지 아닌지 결정하기 위해 모든 상위 클래스를 체크해야 한다.
G. Multiple Inheritance (다중 상속)
실제 다중 상속은 거의 유용하지 않다. base 클래스는 모두 pure interface이거나 혹은 구현을 하려면 하나만 구현을 하도록 한다; 다른 모든 base 클래스는 pure interface(순수 가상 클래스)로 interface 접미사를 클래스 이름 뒤에 붙인다. (예, FooInterface)
정의 :
다중 상속은 sub-class가 하나 이상의 base class를 갖는다. 순수 인터페이스(pure interface)인 base class와 구현부를 갖는 base class를 구분한다.
장점 :
다중 상속은 싱글 상속보다 더 많은 코드를 재사용할 수 있다.
.
단점 :
실제 다중 상속은 거의 유용하지 않다. 다중 상속이 해결책처럼 보일 때, 다른, 보다 명시적인, 보다 깨끗한 해결책을 찾을 수 있다.
결론 :
다중 상속은 모든 super class(첫번 째 클래스는 예외일 수도 있지만)가 pure interface일 경우만 허용한다. 순수 인터페이스(pure interface)를 명시하기 위해 interface 접미사를 붙인다.
Note: windows에서는 이 규칙에 예외가 있다.
어떤 조건을 만족하는 클래스만 가능하며, 요구사항은 아니지만 클래스 이름 뒤에 interface 접미사를 붙인다.
정의 :
클래스는 다음 조건을 만족하면 순수 인터페이스(pure interface)이다.
- 순수 가상 함수(‘= 0’)와 정적 함수만을 갖는다.
- 비정적 데이터 멤버가 없다.
- 정의된 생성자가 필요 없다. 생성자가 있다면 파라미터가 없고, protected여야 한다.
- 서브 클래스라면, 위 조건을 만족하는 클래스로부터 파생되고, interface 접미사를 붙인다.
인터페이스 클래스는 순수 가상 함수 때문에 직접 객체로 만들 수 없다. 인터페이스 구현부가 제대로 소멸되기 위해서 가상 소멸자를 선언해야 한다.
장점 :
클래스에 접두사 I를 붙여서 다른사람들이 메소드를 구현하지 않도록 하며 비정적 멤버를 추가하지 않도록 한다. 다중 상속일 경우 특히 중요하다.
단점 :
interface 접미사를 클래스 이름을 늘려서 읽고 이해하기 어렵게 한다. Interface 속성은 클라이언트에게 노출되선 안되는 세부사항을 고려해서 구현해야 한다.
결론 :
클래스는 위 조건들을 충족시켰을 때만 interface로 끝나야 한다. 그러나 위 조건들을 만족하더라도 interface 접미사를 붙여야 하는 것은 아니다.
I. Operator Overloading (연산자 오버로딩)
특별한 환경이 아니라면, 연산자를 오버로딩 하지 않는다.
정의 :
클래스는 +와 / 같은 연사자를 built-in 타입인 것처럼 동작하도록 정의할 수 있다.
장점 :
클래스가 int와 같은 built-in 타입처럼 동작하기 때문에 보다 직관적이다. 오버로드된 연산자는 Equals()나 Add()와 같은 이름보다 더 잘어울린다.
단점 :
연산자 오버로딩은 직관적인 반면, 몇 가지 결함이 있다.
- 직관적으로 비싼 연산을 싸고, built-in 연산이라고 여기게 만든다.
- 오버로드된 연산을 호출한 부분을 찾기 어렵다. ‘==’보다 Equals()이 찾기 더 쉽다.
- 어떤 연산자는 포인터에서도 동작하며 버그를 만들어낼 여지가 많다. Foo + 4와 &Foo + 4는 완전히 다르다. 컴파일러는 어떤 경고나 에러도 없으며 디버깅이 힘들다.
오버로딩은 관련 이슈가 매우 많다. 예를 들어 클래스가 단항 연산자 operator&를 오버로딩한다면, 안전하게 전방 선언(forward-declared) 될 수 없다.
결론 :
일반적으로 연산자는 오버로딩 하지 않는다. 할당 연산자(operator=)는 특히 조심하고, 피해야 한다. 필요하다면 Equals()와 CopyFrom()과 같은 함수를 정의해서 사용한다. 만약 클래스가 전방 선언될 것 같으면, 위험한 단항 연산자 operator&는 반드시 피한다.
그러나 로그를 위한 operator<<(ostream&, const T&)와 같은 템플릿이나 표준 C++ 클래스와 상호작용하기 위해 연산자를 오버로드할 필요가 있는 경우가 가끔 있다. 가능하지만 피하는 것이 좋다. 특히 클래스가 STL 컨테이너에서 키로 사용된다면 operator== 또는 operator< 는 오버로드 하지 않는다. 대신 컨테이너를 선언할 때 동등/비교 functor 타입을 만든다.
STL 알고리즘의 일부는 operator==를 오버로드 하도록 요구하며, 그런 경우에 오버로드 하되, 오버로드 이유를 문서화 한다.
데이터 멤버는 private로 만들며, 그 멤버에 접근하기 위한 함수를 제공한다. 전형적으로 변수는 foo_와 접근자 함수foo()로 접근하며, 변경자(mutator) 함수 set_foo()가 필요하다. 예외 : static const 데이터 멤버는 private일 필요는 없다.
접근자의 정의는 보통 헤더 파일에 inline 된다.
클래스 내에서 기술된 선언 순서를 사용한다.
public: 다음에 private: , 메소드 다음에 멤버 변수 등
클래스 정의는 public: 섹션으로 시작하고, 다음에 protected: 다음에 private: 섹션 순서로 한다.
섹션이 비었다면 생략한다.
각 섹션내에서 선언은 다음의 순서를 따른다.
- Typedef 와 enum
- 상수 (static const data member)
- 생성자(constructor)
- 소멸자(destructor)
- 메소드, 정적 메소드 포함
- 데이터 멤버 (static const data member 제외)
friend는 항상 private 섹션에 선언한다. DISALLOW_COPY_AND_ASSIGN
은 private:의 맨 끝에 둔다.
.cc(.cpp)파일에서 메소드 정의는 가능하면 선언 순서와 같게 한다.
L. Write Short Functions (함수를 짧게 작성한다)
작고 뚜렷한(focused) 함수를 선호한다.
긴 함수가 때로 적당하며 함수 길이에 제한도 없다. 함수가 40라인을 초과하면 프로그램의 구조를 해치지 않는 한 쪼갤 수 있는지 검토한다.
긴 함수가 완벽히 동작할지라도, 누군가가 몇 달 후에 그 함수에 새로운 행동을 추가할 수 있다. 이것은 찾기 힘든 버그를 발생시킨다. 함수를 짧고 간단하게 유지시키면 다른 사람들이 그 코드를 수정하기 쉽다.
코드 작성시에 길고 복잡한 함수를 찾으면 두려워 말고 수정한다. 그 함수로 작업하기 어렵고, 에러 발생기 디버깅이 힘들고, 다른 문맥에서도 사용하길 원한다면, 더 작고 관리하기 쉬운 조각으로 쪼갠다.