포스트

[Effective C++] Item 01 ~ 04

BRIDGE [씨쁠꾼]에서 진행하는 Effective C++ 스터디의 1주차 정리 내용입니다.

[Effective C++] Item 01 ~ 04
[Item 1] C++를 언어들의 연합체로 바라보는 안목은 필수

현대의 C++는 절차지향, 객체지향, 함수형, 일반화 프로그래밍 등을 포함하고 있다.
많은 기능을 지원하는 만큼 제대로 다루기도 어렵고, 공부해야 하는 양도 방대하다.

C++을 가장 쉽게 공부하는 방법은 크게 4영역으로 분리하여 각 영역을 각개격파하는 것이다.
각 영역은 C, OOP, Template, STL이다.

4영역을 합친다는 맥락에서 연합체의 의미를 포함하고 있다.
각 영역은 구분지어 독립적으로 공부하고 다른 영역으로 넘어오면서 발생하는 규칙의 변화만 이해하자.



[Item 2] #define을 쓰려거든 const, enum, inline을 떠올리자.

#define의 문제점

C에서 기호 상수나 매크로를 선언할 때 #define 키워드를 사용했다.
하지만 #define을 이용하여 기호 상수를 선언할 때는 다음과 같은 문제점이 존재한다.

  • 데이터 타입에 대한 정보가 없다.
  • 기호식 디버거에서 보기 불편하다.
  • 에러 메세지 추적이 불편하다.


#defineconst로 대체하는 방법

위의 문제 중 기호 상수를 보완하는 가장 간단한 방법은 const를 이용하는 방법이다.

1
2
#define ASPECT_RATIO 1.653
const double AspectRatio = 1.653;

상수 정의는 일반적으로 헤더 파일에 하는 것이 관례다.


const 사용하여 기호 상수를 정의할 때 주의해야 하는 2가지 경우가 있다.

상수 포인터의 정의

상수 포인터를 정의할 때는 const의 사용에 주의해야 한다.

  • 포인터를 역참조하여 값을 변경하지 못하도록 const를 사용한다.
  • 포인터 변수를 변경하지 못하도록 const를 사용한다.
1
const std::string const MenteeName = "Jongmin Kim"; 


클래스 상수의 정의

일반적으로 C++에서 변수나 상수를 사용하기 위해서는 정의가 필요하다.

하지만 정적 멤버로 만들어지는 정수류 타입의 클래스 상수는 예외적으로 선언만 있어도 허용되는 경우가 있다.

1
2
3
4
5
6
7
8
// CardDeck.h

class CardDeck
{
private:
    static const int MaxCardCount = 52;
    CardID Cards[MaxCardCount];
};


대신 이 상수의 주소에 접근한다거나, 저장 공간이 필요한 방식으로 쓰면 별도의 정의가 필요하다.
이 경우, 구현 파일에 클래스 상수의 정의를 포함해야 한다.

1
2
3
4
5
6
7
8
9
10
// CardDeck.h

class CardDeck
{
private:
    static const int MaxCardCount = 52;
};

// 주소에 접근하므로 별도의 정의가 필요하다.
const int* ptr = &CardDeck::MaxCardCount;
1
2
3
// CardDeck.cpp

const int CardDeck::MaxCardCount;


위의 문법을 컴파일 에러로 받아들이는 구식 컴파일러의 경우라면 반대로 선언과 정의의 위치를 변경해야 한다.

1
2
3
4
5
6
7
8
// CardDeck.h

class CardDeck
{
private:
    static const int MaxCardCount;
    CardID Cards[MaxCardCount];     // 문제가 없을까요?
};
1
2
3
// CardDeck.cpp

const int CardDeck::MaxCardCount = 52;


하지만 Cards와 같은 배열을 정의할 때 MaxCardCount를 사용하는 경우 문제가 발생한다.
MaxCardCount의 값이 초기값으로 주어지지 않았기 때문이다.

이럴 때 enum hack이라는 방법을 사용할 수 있다.
enum hack열거자의 값은 int의 위치에 사용될 수 있다는 점을 활용한다.

너무 오래된 방법이라고 하니 그냥 이런 역사가 있었구나 생각하면 될듯하다.

1
2
3
4
5
6
7
class CardDeck
{
private:
    enum { MaxCardCount = 52 };

    CardID Cards[MaxCardCount];
};


C++11의 constexpr과 C++17의 inline Variables를 사용하면 enum hack을 사용하지 않고도 해결할 수 있다.

1
2
3
4
5
6
class CardDeck
{
private:
    inline static constexpr int MaxCardCount = 52;
    CardID Cards[MaxCardCount]; 
};


#defineinline으로 대체하는 방법

위의 문제 중 매크로를 보완하는 가장 간단한 방법은 inline을 이용하는 방법이다.
매크로는 불편함도 가지고 있고, 오용될 여지가 많다.

  • 인자마다 괄호를 씌워야만 잘못 해석될 여지가 적다.
  • 잘못 해석될 여지가 적은 것이지 모든 문제가 해결되진 않는다.


예를 들어 두 인자 중 큰 값을 반환하는 매크로 Max(a, b)를 사용하는 예시를 보자.
이 예시에서는 표현식의 평가에 따라 증가 연산자의 호출 횟수가 변함을 보인다.

1
2
3
4
5
#define MAX(a, b) f((a) > (b) ? (a) : (b))

int a = 5, b = 0;
MAX(++a, b);        // ++a가 2번 호출되므로 a는 7이 된다.
MAX(++a, b + 10)    // ++a가 1번 호출되므로 a는 6이 된다.


위의 매크로는 inlineTemplate을 사용하여 개선할 수 있다.

  • 인자마다 괄호를 씌울 필요가 없다.
  • 잘못 해석될 여지가 없다.
  • 함수의 스코프를 제한할 수 있다.
1
2
3
4
5
template<typename T>
inline void Max(T arg1, T arg2)
{
    f(arg1 > arg2 ? arg1 : arg2);
}


[Item 3] 낌새만 보이면 const를 들이대 보자!

const는 객체의 상수성을 코드 수준에서 정하며 컴파일러도 이를 보장해준다.
의미적인 제약은 의도를 보여준다는 점과 실수를 방지한다는 점에서 꽤나 큰 장점이다.


상수 함수

매개변수 타입에서의 const의 사용

객체 내부의 배열 원소를 순차적으로 콘솔에 출력하는 메서드가 있다고 가정하자.
콘솔 출력이 목적이기 때문에 매개변수로 전달되는 객체가 수정되어서는 안된다.

가장 먼저 생각할 수 있는 것은 값을 전달하는 방법이다.
값을 전달하면 PrintArray() 내부에서는 객체의 사본을 통해 작업하므로 수정을 방지한다.

1
void PrintArray(Data data) {...}


하지만 객체가 크기가 큰 경우, 객체의 복사가 비효율적이다.
복사 비용을 줄이면서 수정을 방지하는 방법은 없는 것일까?

이럴 때 포인터나 참조를 전달하면서 const를 이용하여 읽기 전용으로 만들 수 있다.
포인터의 크기는 64비트 시스템에서 8바이트로 정의되기 때문에 객체의 크기가 큰 경우 효율적이다.

1
2
void PrintArray(const Data* data) {...}
void PrintArray(const Data& data) {...}


반환 타입에서의 const의 사용

예를 들어, 유리수에 대한 * 연산자의 오버로딩에 대해서 살펴보자.

1
2
3
class Rational {...}

const Rational operator*(const Rational& lhs, const Rational& rhs);


만약 반환 타입에 const가 없다면 임시 객체를 수정 대상으로 사용하는 저지를 수도 있다.

1
2
3
4
5
6
Rational a, b, c;

if (a * b = c)      // 의도는 a * b == c지만..
{
    ...
}

하지만 반환 타입에 const가 있다면 이를 컴파일 타임에 오류를 잡을 수 있다.

1
2
3
4
if(a * b = c)       // 컴파일 오류; 상수에 값을 대입하려고 시도하니깐.
{
    ...
}


상수 멤버 함수

멤버 함수의 매개변수 뒤에 위치하는 const해당 멤버가 상수 객체에 대해 호출됨을 의미한다.
상수 멤버 함수가 필요한 이유는 다음과 같다.

  • 클래스 인터페이스의 가독성이 높아진다.
  • 개발자의 실수를 방지한다.
  • 상수 객체를 다룰 수 있다.


일반적으로 상수성은 2가지로 분류한다. 비트수준 상수성(Bitwise constness)과 논리적 상수성(logical constness)다.

비트수준 상수성: 상수 함수 내부에서 비트 하나의 변경도 있어서는 안된다.
논리적 상수성: 객체 외부의 관점에서 변경이 없다면 상수성을 가진다.


기본적으로 C++이 추구하는 상수성의 기준은 비트수준 상수성이다.

1
2
3
4
5
6
7
8
9
class CardDeck
{
public:
    // 상수 함수 내부에서의 변경이 하나도 없다.
    std::size_t GetCardCount() const { return CardList.size(); }

private:
    std::vector<CardID> CardList;
};


논리적 상수성은 외부에서 모르게 변경되는 몇 비트는 허용된다는 관점이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class CardDeck
{
public:
    std::size_t GetCardCount() const
    {
        if(!IsCardCountValid)
        {
            CardCount = CardList.size();    // CardCount의 변경; 컴파일 오류.
            IsCardCountValid = true;       // IsCardCountValid의 변경; 컴파일 오류.
        }

        return CardCount;       // 클라이언트 입장에서는 변경을 알 수 없다.
    }

private:
    std::vector<CardID> CardList;
    std::size_t CardCount;
    bool IsCardCountValid;
}


하지만 상수 멤버 함수에서는 비트수준 상수성을 따지기 때문에 위의 코드는 컴파일 오류가 발생한다.
C++는 논리적 상수성을 위해 mutable 키워드를 제공하며 예외적으로 상수 함수 내부에서도 값의 변경을 허용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class CardDeck
{
public:
    std::size_t GetCardCount() const
    {
        if(!IsCardCountValid)
        {
            CardCount = CardList.size();    // CardCount의 변경 허용
            IsCardCountValid = true;       // IsCardCountValid의 변경 허용
        }

        return CardCount;       // 클라이언트 입장에서는 변경을 알 수 없다.
    }

private:
    std::vector<CardID> CardList;
    mutable std::size_t CardCount;  // 상수 함수 내부에서도 값이 변경될 수 있다.
    mutable bool IsCardCountValid;  // 상수 함수 내부에서도 값이 변경될 수 있다.
}


이번에 공부를 하면서 mutable의 존재를 처음 알았다.
게임 개발을 하면서 아직까지 mutable을 써본 적도 없다. 마땅히 쓰일만한 곳이 있는지 궁금하다.


const를 활용한 오버로딩

함수 시그니처에 const를 사용함으로 오버로딩이 가능하다.
항상 가능한 것은 아니고 논리적으로 직관적으로 구분이 가능해야 한다.

이를 테면 값을 전달하는 과정에서 const의 유무는 오버로딩의 대상이 되지 않는다.

1
2
void Foo(int num);
void Foo(const int num);    // 오버로딩 X


또, 포인터 변수와 포인터 상수의 차이도 오버로딩의 대상이 되지 않는다.

1
2
void Foo(Data* data_ptr);
void Foo(Data* const data_ptr); // 오버로딩 X


위의 두 가지 경우를 제외하고서는 모두 오버로딩이 가능하다.


멤버 함수에 const를 사용해서 상수 멤버 함수를 만드는 것도 오버로딩의 대상이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
class TextBlock
{
public:
    const char& operator[](std::size_t position) const
    { return Text[position]; }

    char& operator[](std::size_t position)
    { return Text[position]; }


private:
    std::string Text;
}


상수 멤버 함수와 멤버 함수의 코드 중복 해결

위의 TextBlock 클래스를 보면 const 유무에 따른 [] 연산자 오버로딩이 되어있다.
여기에 경계 검사, 접근 정보 로깅, 무결성 검증 등 다양한 코드를 추가할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class TextBlock
{
public:
    const char& operator[](std::size_t position) const
    { 
        ...     // 경계 검사
        ...     // 접근 정보 로깅
        ...     // 무결성 검증
        return Text[position]; 
    }

    char& operator[](std::size_t position)
    { 
        ...     // 경계 검사
        ...     // 접근 정보 로깅
        ...     // 무결성 검증
        return Text[position]; 
    }


private:
    std::string Text;
}


문제는 두 [] 연산자의 오버로딩 멤버 함수가 const의 유무를 제외하고는 모두 동일하다는 점이다.
경계 검사, 접근 정보 로깅, 무결성 검증을 하나의 private 멤버 함수로 두고 이를 호출한대도 마찬가지다.

이럴 때 멤버 함수 쪽에서 캐스팅을 이용하여 상수 멤버 함수를 호출하면 코드 중복을 줄일 수 있다.
통념적으로 캐스팅이 부정적인 느낌을 주지만 코드 중복도 충분히 쓰레기같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class TextBlock
{
public:
    const char& operator[](std::size_t position) const
    { 
        ...     // 경계 검사
        ...     // 접근 정보 로깅
        ...     // 무결성 검증
        return Text[position]; 
    }

    char& operator[](std::size_t position)
    { 
        return const_cast<char&>(                         // 2. const char&에서 const를 제거하기 위한 캐스팅
               static_cast<const TextBlock&>(*this)[position]); // 1. const [] 연산자 오버로딩을 호출하기 위한 캐스팅 
    }


private:
    std::string Text;
}


[Item 4] 객체를 사용하기 전에 반드시 그 객체를 초기화하자.

C++에서 객체를 초기화하지 않고 사용하면 쓰레기 값으로 인한 미정의 동작을 보인다.
따라서 객체를 사용하기 전에는 반드시 그 객체를 초기화해야만 한다.


C++는 객체 초기화는 크게 2가지로 나뉜다.
기본 제공 타입에 대한 초기화와 사용자 정의 타입에 대한 초기화다.

기본 제공 타입에 대한 초기화

기본 제공 타입에 대한 초기화는 C에서부터 그래왔듯이 직접 초기화를 해야한다.

1
2
3
4
5
int x = 0;
const char* text = "A C-style string";

double d;
std::cin >> d;


사용자 정의 타입에 대한 초기화

기본 제공 타입을 제외하고서는 C++의 객체 초기화는 생성자로 귀결된다.
반드시 기억해야 하는 사실은 대입은 초기화가 아니다.

예를 들어 학생의 정보를 나타내는 Student가 있다고 하자.

1
2
3
4
5
6
7
8
9
10
class Student
{
public:
    ...

private:
    std::string Name;
    int Grade;
    std::string ID;
}


Student의 생성자는 다음과 같이 작성할 수 있다.

1
2
3
4
5
6
Student(const string& name, int grade, const string& id)
{
    Name = name;
    Grade = grade;
    ID = id;
}

C++에서 초기화는 생성자의 본문에 들어가기 전에 끝나야 한다고 명시한다.
하지만 위의 코드는 생성자의 본문에서 대입 연산을 하고 있다.

실제로 위의 std::string 타입인 NameID에 한해 생성자 본문 전에 기본 생성자가 호출된다.
그리고 nameid를 대입하고 있는 꼴이니 기본 생성자는 아무 의미도 없이 낭비된다.


그래서 멤버 이니셜라이저 리스트(member initializer list)를 사용한다.
이는 실질적인 초기화를 의미하며, 낭비되는 기본 생성자도 없다.

1
2
3
4
5
Student(const string& name, int grade, const string& id)
    : Name(name),
      Grade(grade),
      ID(id)
{}

int 타입인 Grade는 기본 생성자가 없으니 대입을 사용해도 무방하지만 통일감을 주는 편이 낫다.
만약 전달할 매개변수가 없다면 단순히 ()만 써도 된다.


마지막으로 비지역 정적 객체의 초기화에 대해서만 살펴보자. 이 경우에는 또 다르다.

정적 객체란?

  • 전역 객체(비지역)
  • 네임스페이스 스코프에 정의된 객체(비지역)
  • 함수 내부에 static으로 선언된 객체(지역)
  • 클래스 내부에 static으로 선언된 객체(비지역)
  • 파일 스코프에 static으로 선언된 객체(비지역)


지역 정적 객체의 초기화 시점은 객체가 처음 사용되는 시점에 초기화된다.
비지역 정적 객체의 초기화 시점은 개별 번역 단위에서 정해진다.

번역 단위하나의 목적 파일을 만드는 데 바탕이 되는 소스 코드를 말한다.
#include를 통해 삽입되는 파일들까지 하나의 번역 단위가 된다.

비지역 정적 객체 초기화의 문제 상황

문제는 비지역 정적 객체가 다른 비지역 정적 객체를 이용할 때 발생됨을 느낄 수 있다.
두 정적 객체의 초기화 순서가 명확하게 정해져 있지 않기 때문이다.


예를 들어, 파일 시스템과 이를 구성하는 디렉터리가 있다고 하자.

1
2
3
4
5
6
7
8
class FileSystem
{
public:
    ...
    std::size_t GetDisksCount() const;
};

extern FileSystem lfs;
1
2
3
4
5
6
7
8
9
10
11
12
13
class Directory
{
public:
    Directory( params );
    ...
};

Directory::Directory( params )
{
    ...
    std::size_t disk_count = lfs.GetDisksCount();  // lfs 객체를 사용한다.
    ...
}


그리고 사용자가 Directory 객체를 하나 생성한다고 가정하자.

1
Directory s_tempDir( params );


Directory의 생성자가 호출되는 시점에 lfs 객체가 생성되어있음을 보장할 수 있을까?
정답은 아니다. 이를 해결할 수 있는 우회책은 지역 정적 객체의 초기화 특징을 이용하는 것이다.

이를 해결하는 간단한 방법은 다음과 같다.

  1. 비지역 정적 객체를 함수 내부에 선언하여 지역 정적 객체로 만든다.
  2. 함수에서 지역 정적 객체에 대한 참조자를 반환한다.

물론 완벽하게 해결하는 방법은 아니지만 적어도 싱글 스레드에서는 문제가 없다.


궁금한 점

  • 헤더에서의 일반적인 인터페이스 순서에 대해서
  • mutable을 사용해본 경험에 대해서
  • 접근 제한자를 어떻게 사용하는지에 대해서
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.