Effective C++/Chapter 1: C++에 왔으면 C++의 법을 따릅시다.

[Effective C++] 항목 3: 낌새만 보이면 const를 들이대 보자!

yeoul0714 2025. 4. 15. 03:40

0. 함수에서 const 위치 개념잡기


0-1. const가 에 있을 경우

const int getValue() {
    return 42;
}

 

이 경우, const는 함수의 반환값이 const라는 것을 의미합니다.

 

반환되는 int 값을 수정할 수 없습니다.

 

하지만 기본 타입(int, double 등)의 반환값은 이미 rvalue(우측값)이므로 수정이 불가능합니다.

 

따라서 기본 타입의 경우 반환 타입에 const를 붙이는 것은 실질적으로 의미가 없습니다.

 

그러나 포인터나 참조를 반환할 때는 의미가 있습니다:

 

const int* getPointer() {
    static int value = 42;
    return &value;  // 포인터가 가리키는 값을 수정할 수 없음
}

 

0-2. const가 에 있을 경우

  int getValue() const {
        return value;
    }

 

이 경우, const는 이 멤버 함수가 객체의 상태를 변경하지 않는다는 것을 의미합니다

  1. 이 함수 내에서 멤버 변수 값을 수정할 수 없습니다.
  2. 이 함수 내에서 const가 아닌 다른 멤버 함수를 호출할 수 없습니다.
  3. const 객체에서 이 함수를 호출할 수 있습니다.

 

1. const의 아름다움


1-1. 값이 불변임을 컴파일러, 다른 프로그래머와 공유 가능

 

1-2. 클래스 밖에서는 전역, 네임스페이스 유효범위의 상수를 선언/정의 가능.

// 전역 범위(global scope)의 상수
const int MAX_USERS = 100;  // 전역 상수

// 네임스페이스 범위(namespace scope)의 상수
namespace Config {
    const double PI = 3.14159;  // 네임스페이스 내 상수
    const int MAX_CONNECTIONS = 50;
}

// 클래스 내부의 상수
class Database {
private:
    const int MAX_ROWS = 1000;  // 클래스 멤버 상수
    
public:
    void someFunction() {
        const int LOCAL_VAR = 10;  // 지역 상수
        // ...
    }
};

int main() {
    // 함수 내부의 지역 상수
    const int BUFFER_SIZE = 512;
    
    // 사용 예시
    int users[MAX_USERS];           // 전역 상수 사용
    double circle = Config::PI * 5; // 네임스페이스 상수 사용
    
    return 0;
}

이렇게 다양하게 const 사용이 가능하다.


 

2. const와 포인터


const는 포인터 변수에도 사용이 가능한데 그 위치에 따라서 의미가 완전히 바뀌게 됩니다.

 

아래가 그 예시입니다.

 

쉽게 설명하자면 const가 *의 앞에 나오게 된다면 (const int * temp) 포인터가 가리키고 있는 값이 상수이고

 

const가 *의 뒤에 나오게 된다면 (int * const temp) 포인터가 상수입니다. (다른 주소 값 저장 불가)

#include <iostream>

int main() {
    int value1 = 10;
    int value2 = 20;
    
    // 1. 데이터가 const (포인터가 가리키는 값을 변경할 수 없음)
    const int* p1 = &value1;
    // *p1 = 30;         // 컴파일 오류: 가리키는 값 변경 불가
    p1 = &value2;         // 가능: 포인터 자체는 변경 가능
    
    // 2. 포인터가 const (포인터 자체를 변경할 수 없음)
    int* const p2 = &value1;
    *p2 = 30;            // 가능: 가리키는 값 변경 가능
    // p2 = &value2;     // 컴파일 오류: 포인터 변경 불가
    
    // 3. 둘 다 const (포인터와 데이터 모두 변경 불가)
    const int* const p3 = &value1;
    // *p3 = 30;         // 컴파일 오류: 가리키는 값 변경 불가
    // p3 = &value2;     // 컴파일 오류: 포인터 변경 불가
    
    std::cout << "value1: " << value1 << std::endl;  // value1: 30 (p2가 변경함)
    std::cout << "value2: " << value2 << std::endl;  // value2: 20
    
    return 0;
}

 

재밌게도 코드를 쓰는 스타일도 다양합니다.

 

아래의 두 코드 모두 같은 역할입니다.

 

둘다 const가 *보다 앞에 와있기 때문에 포인터가 가리키는 값을 바꿀 수 없습니다.

void f1(const Yeoul *yp);

void f2(Yeoul const *yp);

 

3. STL iterator와 const


어떠한 iterator를 const로 선언하는 것은 T* const temp와 유사합니다.

 

즉 가리키는 대상은 바꿀 수 없지만 가리키는 대상 자체의 변경은 가능합니다.

 

만약 대상 자체의 변경을 원치 않는다면 const_iterator를 쓰면 됩니다.

std::vector<int> vec;
...
const std::vector<int>::iterator iter = // iter는 T* const 처럼 동작한다.

*iter = 10; // 대상을 바꾸는 것은 가능
++iter; // iter는 상수여서 에러 발생

std::vector<int>::const_iterator cIter = // const T* 처럼 작동

*cIter = 10; // iter가 가르키는 객체는 바꿀 수 없음
++cIter; // iter가 다른것을 가르키게 할 수 있음

 

 

4. 함수 반환 값을 const로


함수의 반환값을 const로 정해준다면 예상치 못한 실수를 예방 할 수 있습니다.

 

class Yeoul { // };
const Yeoul operator*(const Yeoul& lhs, const Yeoul& rhs);

 

이런식으로 operator*의 반환 값이 굳이 상수일 필요가 있을까? 라고 생각할지도 모릅니다.

 

그러나 이것은 말도안되는 실수를 예방해줍니다.

 

*는 일반적으로 rvalue를 반환하기에 대입이 안되지만

 

자기 자신의 참조를 반환하는 등 lvalue를 반환하도록 구현이 되는 경우도 있습니다.

 

아래가 그 예시이고 lvalue여서 대입연산이 됩니다.

Yeoul a, b, c;

(a * b) = c;

 

이것은 정말 말도 안되는 실수입니다.

 

if(a*b ==c)
{  
    // Do someting
}

이런 코드를 쓰고 싶었을 것입니다.

 

그러나 깜빡 졸았는지 실수로 =을 1개만 쓰고 말았습니다.

 

그러나 const로 선언되어있지 않았기에 아무런 에러도 남기지 않고 컴파일 됐습니다.

 

이러한 일을 방지하기 위해서라도 const를 붙히는 것을 추천합니다.


 

5. const 멤버 함수


5-1. 멤버 함수의 const가 하는 역할을 무엇일까?

 

그 역할은 기본적으로 해당 멤버 함수가 상수 객체에 대해 호출될 함수라는 것을 알려주는 것입니다.

 

근데 이게 왜 중요할까?

 

이유 1 : 클래스의 인터페이스 이해도를 높히려고

- 프로그래머는 기본적으로 해당 클래스에서 객체를 변경할 수 있는 함수는 무엇이고, 아닌 함수는 무엇인지 알아야 합니다.

 

이유 2 : 이 키워드를 통해 상수 객체를 사용하게 함

- C++의 성능을 높히는 기법중에 객체 전달을 상수 객체에 대한 참조자로 진행하는것인데 이것이 제대로 되려면 상수 멤버함수가 준비되어 있어야 합니다.


 

 

5-2. const가 있고 없고의 차이는 오버로딩을 가능하게 한다.

class TextBlock {
private:
    std::string text;

public:
    TextBlock(const std::string& t) : text(t) {}
    
    // non-const 객체를 위한 operator[]
    char& operator[](std::size_t position) {
        return text[position];
    }
    
    // const 객체를 위한 operator[]
    const char& operator[](std::size_t position) const {
        return text[position];
    }
};

 

 

이런식으로 const에 따라서 자동으로 적절한 operator가 호출이 됩니다.

TextBlock tb("Hello");
std::cout << tb[0]; // calls non-const
// TextBlock::operator[]

const TextBlock ctb("World"); 
std::cout << ctb[0]; // calls const TextBlock::operator[]

 

 

이런 경우가 좀더 현실적인 경우입니다.

void print(const TextBlock& ctb) // in this function, ctb is const
{
std::cout << ctb[0]; // calls const TextBlock::operator[] ...
}

 

 

또다른 예시

std::cout<<tb[0]; // 비상수 버전의 TextBlock 객체를 읽음

tb[0] = 'x'; //이것도 됨 수정가능

std::cout<<ctb[0]; // 상수 버전의 객체 읽음

ctb[0] = 'x'; // 상수라서 수정 불가 오류!

 

ctb[0]의 호출 자체가 잘못된 것은 아닙니다.

 

그러나 const char& 타입에 대입 연산을 시도했기 때문에 안되는 것입니다.

 

추가로 operator[]의 비상수 멤버는 char& 타입을 반환하는데 

 

만약에 char만 되어있으면 tb[0] = 'x' 같은건 컴파일이 안됩니다.

 

왜냐면 기본제공 타입의 반환값을 수정하는 일은 안되기 때문입니다.

 

만약 된다해도 의미가 없다 어차피 복사본이고 사라질 값이기 때문입니다.


 

6. 멤버 함수가 const라는 것의 의미


 

6-1. 비트수준 상수성(bitwise constness) / 물리적 상수성(physical constness)

멤버 함수가 그 객체의 어떤 데이터 멤버도 건드리지 않아야 그멤버 함수가 const임을 인정하는 개념

 

어떤 비트도 바꾸면 안된다는 의미입니다.

 

그런데 const로 동작하지 않아도 비트수준 상수성 검사를 통과하는 함수들이 있다는 사실..

 

이 경우는 포인터가 가르키는 대상을 수정하는 경우가 검사를 통과하는 경우에 해당됩니다.

 

해당 포인터만 바꾸지 않으면 컴파일러는 비트수준 상수성을 통과했다고 생각합니다. 

 

그러나 사실 이것으로 인해서 프로그래머의 의도와 상관없는 동작이 생길 수 있습니다.

 

예시를 봅시다.

 

class TextBlock {
private:
    char* text;

public:
    // 비트수준 상수성은 통과하지만, 논리적으로는 const가 아님
    char& operator[](std::size_t position) const {
        return text[position];  // text 포인터 자체는 수정하지 않음
    }
};

 

얼핏보면 문제가 없어 보입니다.

 

const로 되어 있기에 text는 건드리면 안되는건 확실합니다.

 

컴파일러도 문제삼지 않습니다.

 

비트수준에서의 상수성을 지키고 있고 컴파일러는 이것만 검사하기 때문이죠

 

그러나 이는 심각한 문제를 발생킵니다.

 

int main() {
    const TextBlock constBlock("Hello");
    constBlock.print();  // 출력: 현재 텍스트: Hello
    
    // const 객체지만 내용을 변경할 수 있음!
    char& c = constBlock[0];
    c = 'J';
    
    constBlock.print();  // 출력: 현재 텍스트: Jello
    
    return 0;
}

 

이렇게 하면 값이 바뀌게 되어버립니다.

 

아무도 원하는 상황이 아닌것이지요..

 

여기서 논리적 상수성이라는 개념이 나오게 됩니다.


6-2. 논리적 상수성 + mutable

 

논리적 상수성의 핵심 아이디어는 사용자 관점에서 객체가 변경되지 않았다면, 그 객체는 const로 간주할 수 있다는 것입니다.

 

즉, 객체의 관찰 가능한 상태가 변경되지 않는다면, 내부적으로 일부 멤버 변수가 변경되더라도 const로 간주합니다.

class CTextBlock {
private:
    char* pText;
    std::size_t textLength;    // 텍스트 길이를 캐싱
    bool lengthIsValid;        // 캐시가 유효한지 여부

public:
    CTextBlock(const char* text) 
        : pText(new char[std::strlen(text) + 1]), 
          lengthIsValid(false) {
        std::strcpy(pText, text);
    }
    
    
    // 텍스트 길이를 반환하는 함수
    std::size_t length() const {
        if (!lengthIsValid) {
            textLength = std::strlen(pText);  // 오류 const함수 안에서 변경 불가
            lengthIsValid = true;             // 오류 const함수 안에서 변경 불가
        }
        return textLength;
    }
};

 

이러한 경우는 비트적 상수성과는 거리가 멉니다.

 

그러나 CTextBook이라는 상수 객체에 대해서는 사실 문제가 없습니다.

 

그러나 이 코드는 컴파일이 불가능 합니다.

 

그래서 우리는 mutable이라는 키워드를 사용합니다.

class CTextBlock {
private:
    char* pText;
    mutable std::size_t textLength;    // mutable로 변경
    mutable bool lengthIsValid;        // mutable로 변경

public:
    CTextBlock(const char* text) 
        : pText(new char[std::strlen(text) + 1]), 
          lengthIsValid(false) {
        std::strcpy(pText, text);
    }
    
    
    // 텍스트 길이를 반환하는 함수
    std::size_t length() const {
        if (!lengthIsValid) {
            textLength = std::strlen(pText);  // 이제 오류 안남
            lengthIsValid = true;             // 이제 오류 안남
        }
        return textLength;
    }
};

 


6-3. mutable로도 해결 못하는 문제

mutable로 const를 사용할때 생기는 모든 문제를 해결하지 못합니다.

 

여기 또 다른 문제가 존재합니다.

 

이런식으로 []연산자에서 단순 텍스트 반환 이외에도 여러가지 일들을 해줄 수 있습니다.

class TextBlock {
private:
    std::string text;

public:
    TextBlock(const std::string& t) : text(t) {}
    
    // non-const 버전 (일반 객체용)
    char& operator[](std::size_t position) {
        // 경계 검사
        if (position >= text.size()) {
            throw std::out_of_range("위치가 범위를 벗어났습니다");
        }
        
        // 접근 로깅
        std::cout << "non-const operator[] 호출: 위치 " << position << std::endl;
        
        // 텍스트 반환
        return text[position];
    }
    
    // const 버전 (const 객체용)
    const char& operator[](std::size_t position) const {
        // 경계 검사 (중복 코드)
        if (position >= text.size()) {
            throw std::out_of_range("위치가 범위를 벗어났습니다");
        }
        
        // 접근 로깅 (중복 코드)
        std::cout << "const operator[] 호출: 위치 " << position << std::endl;
        
        // 텍스트 반환
        return text[position];
    }
};

 

그렇다면 완전히 똑같은 로직을 2번씩 코드에 써야하는 불편함이 생깁니다.

 

사실 위의 코드둘은 const가 붙어있냐 아니냐만 다르고 다른 로직은 완전히 똑같습니다.

 

이러한 경우엔 cast를 이용해서 const를 업애더라도 안전합니다.

 

결론적으로 비상수 operator[]가 상수 버전을 호출하도록 구현하면 됩니다.

 

예시를 보자.

 

 

이러한 문제는 어떻게 해결 가능할까?

 

class TextBlock {
private:
    std::string text;

public:
    TextBlock(const std::string& t) : text(t) {}
    
    // non-const 버전이 const 버전을 호출
    char& operator[](std::size_t position) {
        // const_cast: this에서 const를 제거
        // static_cast: *this를 const 참조로 변환
        return const_cast<char&>(
            static_cast<const TextBlock&>(*this)[position]
        );
    }
    
    // const 버전에 모든 실제 구현 코드 포함
    const char& operator[](std::size_t position) const {
        // 경계 검사
        if (position >= text.size()) {
            throw std::out_of_range("위치가 범위를 벗어났습니다");
        }
        
        // 접근 로깅
        std::cout << "operator[] 호출: 위치 " << position << std::endl;
        
        // 텍스트 반환
        return text[position];
    }
};

 

우리는 비상수 operator[]가 상수 버전을 호출하게 해야합니다.

 

그런데 operator[]에서 그냥 operator[]로 적어버리면 자기 자신을 호출해서 무한 재귀에 빠지게 됩니다.

 

그래서 상수 operator[]를 호출하라고 알려줘야하는데 그 방법이 바로 *this를 캐스팅 하는 것입니다.

 

그래서 static_cast<const TextBlock&>(*this)[position] 이부분은 const 참조로 바꾸는 것이고

 

결국 const 버전의 operator[]를 호출하게 됩니다.

 

그런데 이곳은 const가 없는 버전의 operator[]이므로 마지막으로 const_cast<char&>를 통해서 const를 제거 해줍니다.

 

솔직히 코드가 보기 굉장히 힘들기 하지만 중복을 피하는 훌륭한 방법입니다.

 

 

주의 : 상수 버전이 비상수 버전 호출은 금물

만약 상수 버전에서 비상수 버전을 호출하게 되면 그 안에서 객체의 내용이 변하는 코드를 허용하게 되며

 

객체를 변경하지 않겠다는 const와의 진한약속이 비상수 버전 함수안에서 어겨질 가능성이 있습니다.

 

또한 컴파일러가 const 함수에서 non-const 함수를 호출하는 것을 기본적으로 막게 되는데

 

억지로 호출하려고 cast를 사용하다보면 재앙의 씨앗이 됩니다.

 

 

이것만은 잊지 말자!

  • const를 붙여 선언하면 컴파일러가 사용상의 에러를 잡는데 도움을 준다. 
    const는 어떤 유효범위에 있는 객체에도 붙고, 함수나 매개변수 타입, 반환 타입에도 붙고 멤버 함수에도 붙을 수 있다.

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

  • 상수 멤버 및 비상수 멤버 함수가 기능적으로 똑같게 구현 되어 있으면 중복을 피해야 하는데 이때 비상수 버전이 상수 버전을 호출하게 해야한다.

항목 4

https://yeoul0714.tistory.com/30

 

[Effective C++] 항목 4: 객체를 사용하기 전에 반드시 그 객체를 초기화하자

1. 초기화 여부를 파악하긴 난해하다.C++의 변수 초기화가 중구난방은 아니다. 초기화가 보장되는 경우와 아닌경우가 분명하게 존재한다. 그러나 그 조건을 전부 알고있기엔 너무나 복잡하다. 기

yeoul0714.tistory.com