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는 이 멤버 함수가 객체의 상태를 변경하지 않는다는 것을 의미합니다
- 이 함수 내에서 멤버 변수 값을 수정할 수 없습니다.
- 이 함수 내에서 const가 아닌 다른 멤버 함수를 호출할 수 없습니다.
- 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
'Effective C++ > Chapter 1: C++에 왔으면 C++의 법을 따릅시다.' 카테고리의 다른 글
[Effective C++] 항목 4: 객체를 사용하기 전에 반드시 그 객체를 초기화하자 (0) | 2025.04.17 |
---|---|
[Effective C++] 항목 2: #define을 쓰려거든 const, enum, inline을 떠올리자 (0) | 2025.04.12 |
[Effective C++] 항목 1: C++를 언어들의 연합체로 바라보는 안목은 필수 (0) | 2025.04.12 |