Patrick's Devlog

[Effective C++] Chapter 1 정리 - 2 본문

Study/C++

[Effective C++] Chapter 1 정리 - 2

Patrick_ 2022. 5. 11. 19:05

개요

분량이 많아지다 보니 나눠서 작성하게 되었다. 3, 4항목만 정리한다. 내가 알고 있던 C++보다 훨씬 깊게 배워 읽는 속도가 느려 정리가 늦어진다. 빠르게 한다기 보다, 이해를 목적으로 천천히 정리하려고 한다. 


Item 3: 낌새만 보이면 const를 들이대 보자

const는 여러 방면에서 쓰인다. 클래스 외부에서는 전역 혹은 네임스페이스 유효 범위의 상수를 선언하는데 쓸수 있다. 이뿐만 아니라, 파일, 함수, 블록 유효 범위에서 static으로 선언한 객체에서도 const를 붙일 수 있다. 클래스 내부에서는 정적 멤버 및 비정적 데이터 멤버 모두를 상수로 선언할 수 있다.

또한 포인터는 포인터 자체를 상수로, 혹은 포인터가 가리키는 데이터를 상수로 지정할 수 있다. 여기서 둘 다 지정할 수 있고 아무것도 지정하지 않을수도 있다.

char greeting[] = "Hello";
char *p = greeting;               // 비상수 포인터, 비상수 데이터
const char *p = greeting;         // 비상수 포인터, 상수 데이터
char * const p = greeting;        //상수 포인터, 비상수 데이터
const char * const p = greeting;  // 상수 포인터, 상수 데이터

const 키워드가 별표의 왼쪽에 있으면 포인터가 가리키는 대상이 상수이고 별표의 오른쪽에 있을 땐 포인터 자체가 상수이다. 만약 양쪽에 다 있으면 포인터가 가리키는 대상 및 포인터가 상수라는 뜻이다. 

 

const 키워드는 프로그래머마다 스타일이 다르다. 하지만 아래의 코드는 받아들이는 매개변수 타입은 동일하다는 의미를 지닌다.

void f1(const Widget *pw);
void f2(Widget const *pw);

현업에서 자주 쓰이므로 익히는 것이 좋다.

 

STL iterator는 포인터를 본뜬 것이므로, 기본적인 동작 원리가 T* 포인터와 흡사하다. 어떤 iterator를 const로 선언하는 일은 포인터를 상수로 선언하는 것과 동일하다. iterator는 자신이 가리키는 대상이 아닌것을 가리키는 경우가 허용되지 않으나, iterator가 가리키는 대상 자체는 변경이 가능하다. 만약 변경이 불가능한 객체를 가리키는 iterator가 필요하면 const_iterator를 사용하면 된다. 

 

가장 강력한 const의 용도는 함수 선언에 썼을 경우이다. 함수 선언문에 있어 const는 함수 반환 값, 각각 매개변수, 멤버 함수 앞에 붙을 수 있고 함수 전체에 대해 const 성질을 붙일 수 있다.

함수 반환값을 상수로 정해주면, 안전성이나 효율을 포기하지 않고 에러 돌발 상황을 줄이는 효과를 자주 볼 수 있다.

class Rational { ... };

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

여기서 operator*의 반환 값이 상수일리 없을 것이라 생각할텐데, 상수 객체로 되지 않으면 아래와 같은 실수를 저지르게 된다.

Rational a, b, c;
...
(a * b) = c;

const를 직접 지정하게 되면 이러한 실수 또한 컴파일 에러로 이어지지 않는다. 

 

상수 멤버 함수

멤버 함수에 붙는 const는 해당 멤버 함수가 상수 객체에 대해 호출될 함수임을 알려주는 것이다. 이러한 함수의 중요성은 두가지로 확인할 수 있다.

 

 1. 클래스의 인터페이스를 이해하기 좋게 하기 위해

 2. 이 키워드를 통해 상수 객체를 사용할 수 있게 하는 것

 

const 키워드의 유무 차이만 있는 멤버 함수는 오버로딩이 가능하다. 아래의 클래스 예시를 한번 보자.

class TextBlock {
public:
...
    const char& operator[](std::size_t position) const // 상수 객체에 대한 operator[]
    { return text[position]; }
    char& operator[](std::size_t position) // 비상수 객체에 대한 operator[]
    { return text[position]; }
    
private:
    std:string text;
};

위처럼 선언된 operator[]는 다음과 같이 쓰일 수 있다

TextBlock tb("Hello");
std::cout << tb[0]; // TextBlock::operator[]의 비상수 멤버 호출

const TextBlock ctb("World");
std::cout << ctb[0]; // TextBlock::operator[]의 상수 멤버 호출

실제 프로그램에서 상수 객체가 생기는 경우는 상수 객체에 대한 포인터나 상수 객체에 대한 참조자로 객체가 전달될 때이다. 위의 코드는 이해를 돕기위한 코드일 뿐이다. 아래의 경우가 조금 더 실무에 쓰인다.

void proint(const TextBlock& ctb) { // 이 함수에서 ctb는 상수 객체로 쓰임
    std::cout << ctb[0]; // TextBlock::operator[]의 상수
    ...  // 멤버 호출
}

operator[]를 오버로드해서 각 버전마다 반환 타입을 다르게 가져갔으므로, TextBlock의 상수 객체와 비상수 객체 쓰임새가 달라진다. 

std::cout << tb[0]; // 비상수 버전 TextBlock 객체를 읽음
tb[0] = 'x'; // 비상수 버전의 TextBlock 객체를 씀
std::cout << ctb[0]; // 상수 버전의 TextBlock 객체를 읽음
ctb[0] = 'x'; // 컴파일 에러, 상수 버전의 TextBlock 객체에 대해 쓰기는 안됨

넷째 줄에서 발생한 에러는 순전히 operator[]의 반환 타입으로 인해 생긴 것이다. const char& 타입에 대입 연산을 시도했으므로 생긴 에러이다. 여기서 operator[]의 비상수 멤버는 char의 참조자(reference)를 반환하는데 char 하나만 쓰면 안된다. 만약 그냥 char만 반환할 시 둘째 줄 문장이 컴파일 되지 않는다. 기본제공 타입을 반환하는 함수의 반환 값을 수정하는 일은 있을 수 없기 때문이다. 

 

어떤 멤버 함수가 상수 멤버(const)라는 것에 대해 가지는 개념은 두 가지가 존재한다.

 

1. 비트수준 상수성(bitwise constness, 물리적 상수성)

어떤 멤버 함수가 그 객체의 어떤 데이터 멤버도 건들이지 않아야(정적 멤버 제외) 그 멤버 함수가 'const'임을 인정하는 것이다. 쉽게 말해 그 객체를 구성하는 비트 중 어떤 것도 바꾸면 안된다. 이를 사용하면 상수성 위반을 발견하기 쉽다. 하지만, 제대로된 const가 동작하지 않는데도 이 검사를 통과하는 멤버 함수들이 존재한다. 

class CTextBlock {
public:
    ...
    char& operator[] (std::size_t position) const // 부적절하지만 비트 수준 상수성에 있어 허용됨
    { return pText[position]; }
    
private:
    char *pText;
};

위의 코드로 예를 들어보자. 코드에 나와있듯 operator[] 함수는 상수 멤버로 선언되어 있다. 그럼에도 불구하고 해당 객체의 내부 데이터에 대한 참조자를 반환한다. operator[] 내부 코드만 확인해보면 pText는 안건들이는 것을 확인할 수 있다. 

const CTextBlock cctb("Hello"); // 상수 객체 선언
char *pc = &cctb[0]; // 상수 버전의 operator[]를 호출해 cctb의 내부 데이터에 대한 포인터를 얻음
*pc = 'J'; // cctb는 "Jello"라는 값을 갖게 됨

하지만 어떤 값으로 초기화된 상수 객체를 만들고 이에 상수 멤버 함수를 호출하니 값이 변경되었다. 

 

2. 논리적 상수성(logical constness)

위와 같은 문제점을 보완하기 위해 대체된 개념이 논리적 상수성이다. 객체의 일부 비트는 바꿀수 있되, 이를 사용자측에서 알아채지 못하게 하면 자격이 있다는 뜻이다. 아래의 예시를 보자.

class CTextBlock {
public:
    ...
    std::size_t length() const;

private:
    char *pText;  
    std::size_t textLength; // 바로 직전 계산한 텍스트 길이
    bool lengthIsValid; // 이 길이가 유효한지
};

std::size_t CTextBlock::length() const {
    if (!lengthIsValid) {
        textLength = std::strlen(pText); // 에러, 상수 멤버 함수 안에서는 textLength 및
        lengthIsValid = true;            // lengthIsValid에 대입 불가능
    } 
    return textLength;
}

CTextBlock 클래스는 문장 구역 길이를 사용자들이 요구할 시 정보를 캐시해 둘 수 있는데, 위의 코드와 같은 멤버를 둘 수 있다. length의 구현은 비트수준 상수성과 떨어져있다. textLength와 lengthIsValid가 바뀔수 있기 때문이다. 상수 객체에 대해서는 문제가 없어야할 것같은 코드지만, 컴파일 에러는 발생한다. 이를 고치기 위해서는 textLength와 lengthIsValid 앞에 mutable 키워드를 붙여준다

    mutable std::size_t textLength;
    mutable bool lengthIsValid;

mutable은 비정적 데이터 멤버를 비트수준 상속성의 족쇄를 풀어주는 키워드이다. 

 

상수 멤버 및 비상수 멤버 함수에서 코드 중복 현상을 피하는 방법

mutable로 const와 관련된 문제를 전부 해결하진 못한다. TextBlock 및 CTextBlock의 operator[]가 지금은 특정 문자와 참조자만 반환하고 있지만, 여러 가지를 더 할수도 있다. 여러 가지 코들르 모조리 operator[]의 상수/비상수 버전에 넣으면 비슷한 코드가 엉켜있게 될 것이다.

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;
};

이렇게 코드가 부풀어지면 컴파일 시간은 물론 유지보수도 어려워진다. 코드 중복은 결코 좋은것이 아니다. operator[]의 상수 버전은 비상수 버전과 비교해서 하는일이 동일함을 확인할 수 있다. 다른 점은 const가 붙는다는 것이다. 따라서 여기서 캐스팅을 써서 반환 타입으로부터 const 껍데기를 없애도 안전하다. 캐스팅이 필요하지만, 안전성도 유지하며 코드 중복을 피하는 방법은 비상수 operator[]가 상수 버전을 호출하도록 구현하는 것이다. 

class TextBlock {
public:
    const char& operator[](std::size_t position) const {
        .... // 경계검사, 접근 데이터 로킹, 자료 무결성 검증 등 여러 코드 (이전과 동일)
        return text[position];
    }
    char& operator[](std::size_t position) {
        return const_cast<char&> { // op[] 반환 타입에 캐스팅 적용, const를 떼어냄
            static_cast<const TextBlock&> (*this) [position] // * this 타입에 const 붙이고 op[]의 상수버전 호출
        };
    }
    ...
};

 

 ★ const를 붙여 선언하면 컴파일러가 사용상 에러를 잡아내는데 도움을 준다. const는 어떤 유효 범위에 있는 객체에도 붙일 수 있으며 함수 매개변수 및 반환 타입에도 붙을 수 있다. 또한 멤버 함수에도 붙을 수 있다

 ★ 컴파일러 쪽에서 보면 비트수준 상수성을 지켜야 하지만, 우리는 논리적인 상수성을 이용해 프로그래밍 해야한다.

 ★ 상수 멤버 및 비상수 멤버 함수가 기능적으로 서로 똑같게 구현되어 있을 경우 코드 중복을 피하는 것이 좋은데, 이때 비상수 버전이 상수 버전을 호출하도록 한다. 

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

class Point {
    int x, y;
};
...
Point p;

위와 같이 코드를 했을 때 p의 데이터 멤버는 어떤 상황에서 초기화가 보장되지만 어쩔 때는 되지 않는다. 가끔 초기화 하지 않으면 프로그램이 멈추는 경우도 존재하며, 객체 자체가 이상한 값을 가지게 되는 경우도 존재한다. 이런 경우가 계속 생기지 않지만, 방지하기 위해서는 객체를 사용하기 전 항상 초기화를 하는 것이다. 

 

생성자에서 지킬 규칙은 객체의 모든 것을 초기화하면 된다. 하지만 여기서 대입(assignment)을 초기화(initialization)와 헷갈리지 않는 것이 중요하다. 아래의 예를 확인해보자

class PhoneNumber { ... };

class ABEntry {
public:
    ABEntry(const std::string& name, const std::string& address, const std::list<PhoneNumber> & phones);
private:
    std::string thename;
    std::string theAddress;
    std::list<PhoneNumber> thePhones;
    int numTimesConsulted;
};

ABEntry:ABEntry(const std::string& name, const std::string& address, const std::list<PhoneNumber>& phones)
{
    theName = name;         // 여기는 모두 대입을 진행하고 있음 (초기화 X) 
    theAddress = address;
    thePhones = phones;
    numTimesConsulted = 0;
}

ABDEntry 생성자를 살펴보면 값이 대입되고 있는 것을 확인할 수 있다. 여기서 그럼 numTimesConsulted도 미리 초기화되었나 의문을 가질수 있다. 대답은 No이다. 꼭 그렇지만은 않다. 기본제공 타입의 데이터 멤버이기 때문에, 대입전에 초기화가 되리란 보장이 없다. ABEntry 생성자를 조금 더 잘 만들면 초기화가 가능하다

ABEntry::ABEntry(const std::string& name, const std::string& address, const std::list<PhoneNumber>& phones)
: theName (name),        // 이들은 모두 초기화 되고 있음
  theAddress (address),
  thePhones(phones),
  numTimesConsulted(0)
{}  // 생성자 본문엔 아무것도 안 들어감

데이터 멤버에 사용자가 원하는 값을 주고 시작한다는 점에서는 동일하지만 위의 코드보다 효율적이다. 

 

이렇게 비맴버 객체도 초기화하고 클래스 생성자의 멤버 초기화 리스트를 사용해 기본 클래스 및 데이터 멤버까지 초기화하면 걱정 거리는 하나만 남았다. 비지역 정적 객체 초기화 순서는 개별 번역 단위에서 정해진다는 것이다. 이해하기 전 낱말을 하나씩 살펴보자

 

정적 객체(static object)는 자신이 생성된 시점부터 프로그램이 끝날때까지 살아있는 객체를 의미한다. 정적 객체의 범위는 전역 객체, 네임스페이스 유효범위에서 정의된 객체, 클래스 안에서 static으로 선언된 객체, 함수 안에서 static으로 선언된 객체, 파일 유효범위에서 static으로 정의된 객체 총 5가지이다. 함수 안에 존재하는 객체는 지역 정적 객체이고 나머지는 비지역 정적 객체이다. 

 

번역 단위(tranlation unit)는 컴파일을 통해 하나의 Object File을 만드는 바탕이 되는 소스코드를 일컫는다. 번역은 소스 언어를 기계어로 옮긴다는 의미이다. 기본적으로는 소스파일 하나가 되는데, 그 파일이 #include하는 파일까지 합쳐서 하나의 번역단위가 된다.

 

따라서 이 낱말을 모두 합치면 별도로 컴파일된 소스 파일이 두 개이상 존재하며 각 소스 파일에 비지역 정적 객체가 한 개 이상 들어있는 경우에 어떻게 되느냐이다. 한쪽 번역 단위에 있는 비정적 객체의 초기화가 진행되면서 다른쪽 번역 단위에 있는 비지역 정적 객체가 사용되는데, 다른쪽 번역 단위에 있는 객체가 초기화되어 있지 않을수도 있다. 이유는 별개의 번역 단위에서 정의된 비지역 정적 객체들의 초기화 순서는 정해져 있지 않기 때문이다. 이해가 어려우면 예제를 살펴보자

class FileSystem { // 우리 라이브러리에 포함된 클래스
public:
    ...
    std::size_t numDisks() const; // 많고 많은 멤버 함수들 중 하나
    ...
};

extern FileSystem tfs; // 사용자가 쓰게 될 객체

이 클래스는 인터넷에 있는 파일을 로컬 컴퓨터에 있는 것처럼 보이게 하는 파일 시스템을 나타낸다. 

class Directory {
public:
    Directory { params };
    ...
};

Directory::Directory( params )
{
    ...
    std::size_t disks = tfs.numDisks();
    ...
}

이제 사용자가 Directory 클래스를 사용해 임시 파일을 담는 디렉토리 객체 하나를 생성한다.

Directory tempDir( params ); // 임시 파일을 담는 디렉토리

tfs가 tempDir보다 먼저 초기화되지 않으면 tempDir의 생성자는 tfs가 초기화되지 않은 상태로 사용하려 한다. 하지만 tfs와 tempDir은 제작자도 다르고 만들어진 시기도 다르고 소재지도 다르다. 이를 정리하면 다른 번역 단위 안에서 정의된 비지역 정적 객체라는 뜻이다. tempDir 전에 tfs가 초기화되게 하는 것은 불가능하다. 즉, 서로 다른 번역 단위에 정의된 비지역 정적 객체들 사이의 상대적인 초기화 순서는 정해져 있지 않다. 그래도 이 문제는 설계에 약간의 변화만 주면 사전에 방지할 수 있는 부분이다. 비지역 정적 객체를 하나씩 맡는 함수를 준비한 후 이 안에 객체를 넣으면 된다. 함수 속에서 이들은 정적 객체로 선언하고 그 함수에서는 이들에 대한 참조자를 반환하게 한다. 그리고 사용자 쪽에서는 함수 호출로 대신한다. 그럼 '비지역 정적 객체'가 '지역 정적 객체'로 바뀐다. 이는 흔히 우리가 디자인 패턴에서 배운 Singleton pattern의 구현 방법임을 알 수 있다. 

 

class FileSystem { ... }; // 이전 코드와 동일
FileSystem& tfs() {  // tfs객체를 이 함수로 대신. 이 함수는 클래스안에 정적 멤버로 들어가도 됨
    static FileSystem fs; // 지역 정적 객체 정의 및 초기화
    return fs; // 이 객체에 대한 참조자 반환
}

class Directory { ... };

Directory::Directory( params )
{
    ...
    std::size_t disks = tfs().numDisks(); // ()만 덧붙이기
    ...
}

Directory& tempDir() { // tempDir 객체를 이 함수로 대신. 이 함수는 Directory 클래스의 정적 멤버로 들어가도 됨
    static Directory td; // 지역 정적 객체를 정의 및 초기화
    return td; // 이 객체에 대한 참조자 반환
}

여기서 tfs와 tempDir은 ()만 붙으면 된다. 정적 객체 자체를 직접 사용하지 않고 그 객체에 대한 참조자를 반환하는 함수를 사용할 수 있다. 

Singleton Pattern을 사용하게 되면서 많은 문제들도 해결되었지만, 문제점 또한 존재한다. 내부적으로 정적 객체를 쓰기 때문에 다중 스레드 시스템에서는 동작에 장애가 발생할 수 있다. 이러한 방법을 방지하기 위해서는 프로그램이 다중 스레드로 돌입하기 전 시동 단계에서 참조자 반환 함수를 전부 손으로 호출할 수 있다. 이렇게 하면 초기화에 관계된 race condition이 없어진다. 

 

 ★ 기본제공 타입의 객체는 직접 손으로 초기화한다. 경우에 따라 저절로 되기도하고 아니기도 하다.

 ★ 생성자에서는 데이터 멤버에 대한 대입문을 생성자 본문 내부에 넣는 방법을 초기화하지 말고 멤버 초기화 리스트를 사용하자. 그리고 초기화 리스트에 데이터 멤버를 나열할 때 클래스에 각 데이터 멤버가 선언된 순서와 똑같이 나열하자.

 ★ 여러 번역 단위에 있는 비지역 정적 객체들의 초기화 순서 문제는 피해서 설계해야 한다. 비지역 정적 객체를 지역 정적 객체로 바꾸면 된다. 

'Study > C++' 카테고리의 다른 글

[Effective C++] Chapter 2 정리 - 1  (0) 2022.05.26
[Modern C++] Lvalue 및 Rvalue  (0) 2022.05.20
[Modern C++] Smart Pointer  (0) 2022.05.17
[Modern C++] 시작하기  (0) 2022.05.16
[Effective C++] Chapter 1 정리 - 1  (0) 2022.05.09