본문 바로가기
C++공부/Effective C++

Effective C++ 항목 3: const를 자주 사용하자

by daisy0461 2024. 10. 7.

const 변수

const의 가장 큰 장점은 의미적인 제약을 소스 코드 수준에서 붙인다는 점과 컴파일러가 이 제약을 지켜준다는 것이다.

데이터 멤버는 포인터 자체를 상수로, 혹인 포인터가 가리키는 데이터를 상수로 지정 가능하다.

char c[] = "JJune";
char* p = c;				//비상수 포인터 & 데이터
const char* p = c;			//비상수 포인터 & 상수 데이터
char* const p = c;			//상수 포인터 & 비상수 데이터
const char* const p = c;		//상수 포인터 & 상수 데이터

위를 보면 알겠지만 const가 *보다 왼쪽에 있으면 가르키는 대상(데이터)이 상수이고

const가 *보다 오른쪽에 있으면 포인터 자체가 상수가 된다.

 

위의 말을 추가로 더 설명하자면

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

위 f1, f2함수는 const가 *보다 오른쪽에 있기에 가르키는 대상(데이터)이 상수이다.

 

또한 STL 반복자는 포인터를 본뜬 것이기에 기본적인 동작이 T*와 비슷하다.

즉 어떤 반복자를 const로 선언하는 것은 포인터를 상수로 선언하는 것과 같다.(T* const)

그리고 반복자는 자신이 가리키는 대상이 아닌 것을 가리키는 경우가 허용되지 않지만 반복자가 가리키는 대상 자체는 변경이 가능하다.

만약 변경이 불가능한 객체를 가리키는 반복자(const  T*)가 필요하다면 const_iterator를 사용하면 된다.

vector<int> vec;

const vector<int>::iterator i = vec.begin();

*i = 10;		//i가 가르키는 대상(*i)을 변경한다.
i++;			//i는 상수이기 때문에 변경 불가능하다. 에러

vector<int>::const_iterator ci = vec.begin();

*ci = 10;		//*ci가 상수이기 때문에 불가능하다. 에러
ci++;			//ci를 변경하는 것은 가능하다.

여기서 iterator가 T*과 비슷한데 둘다 그럼 const가 *보다 왼쪽에 있는데? 헷갈린다. 라고 생각이 드신다면

const_iterator를 명확하게 iterator의 왼쪽에 const가 있기에 const T*구나라고 생각하면 편할 것 같습니다.

 

const 함수

const는 함수에서도 사용이 가능하다.

const는 함수 선언문에 함수 반환 값, 매개변수, 멤버 함수 앞에 붙을 수 있고 함수 전체에 const 성질을 붙일 수도 있다.

 

특히 함수 반환 값을 상수로 정해주면 안정성과 효율을 포기하지 않고 사용자측의 에러를 줄여줄 수 있다.

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

operator*의 반환 값이 왜 상수 객체여야하는 가는 이런 실수를 막아줄 수 있다.

Rational a, b, c;

(a * b) = c;

누가 이런 짓은 한다고 생각할 수 있지만 '실수'가 발생할 수 있다.

이런 실수가 발생했을 때 함수 반환 값을 const로 설정해주면 방지가 가능하다.

 

상수 멤버 함수

멤버 함수에 붙는 const 키워드의 역할은 "해당 멤버 함수가 상수 객체에 대해 호출될 함수"를 알려준다.

이 함수가 중요한 이유는 두가지가 있다.

  1. 클래스의 인터페이스의 이해를 돕기 위해
    해당 클래스로 만들어진 객체를 변경할 수 있는 함수는 무엇이고 변경 불가능한 함수는 무엇인지 알 수 있다.
  2. 상수 멤버 함수를 통해 상수 객체에서 사용할 수 있게 하자.
    이는 코드의 효율을 높이는 부분이기도 하다. 실행 성능을 높이는 핵심 기법 중 하나가 객체 전달을 '상수객체'에 대한 참조자(reference to const)로 진행하는 것이기 때문이다. 이는 이후 글에 정리한다.

이 기법에 제대로 사용되기 위해서는 상수 상태로 전달된 객체를 조작할 수 있는 const 멤버 함수가 있어야한다.

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

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

그리고 위처럼 const 키워드의 있고 없고의 차이면 있다면 오버로딩을 할 수 있다.

사용되는 예시는 다음과 같다.

TextBlock tb("JJ");
cout << tb[0];		//[]의 비상수 멤버 함수 호출

const TextBlock ctb("JJ");
cout << ctb[0];		//[]의 상수 멤버 함수 호출

tb[0] = 'x';		//비상수 이기 때문에 TextBlock 객체를 쓴다.
ctb[0] = 'x'; 		//상수 버전의 객체에 대해서 쓰기는 불가능하다. 에러.

여기서 ctb[0] = 'x'; 에서 발생한 에러는 []의 반환 타입 때문에 발생한 에러이다.

const char&라는 반환 타입에 대입 연산을 시도하였기 때문에 에러가 발생한다.

 

비트수준 상수성(물리적 상수성)과 논리적 상수성

어떤 멤버 함수가 const라는 것은 두가지 개념이 있다.

비트수준 상수성과 논리적 상수성이다.

 

비트수준 상수성

비트수준 상수성이란 어떤 멤버 함수가 그 객체의 어떤 데이터 멤버도 건드리지 말아야 const임을 인정하는 개념이다.

C++에서 정의하는 상수성이 비트수준 상수성이다.

간단하게 객체를 구성하는 비트들 중 어떤 것도 바꾸면 안된다는 개념이다.

당연히 그럼 변경이 불가능한 것처럼 보이지만 그렇지 않은 경우가 있다.

class CTextBlock {
	char& operator[] (size_t position) const
	{
		return pText[position];
	}

private:
	char* pText;
};

위에서 보면 operator[]가 상수 멤버함수로 선언되어있다. 또한 const이니 함수 내부에선 값을 변경하지 않는다.

하지만 중점은 객체의 내부 데이터에 대한 참조자를 반환한다는 것이다.

이게 무슨 잘못인가?

아래 코드를 보면 감이 온다.

const CTextBlock cctb("JJ");		//상수 객체 선언

char *pc = &cctb[0];			//상수 버전의 []를 호출하여 내부 데이터에 대한 포인터를 얻는다.

*pc = 'H';				//이제 cctb는 "JJ"가 아니라 "HJ"라는 값을 갖는다.

이상하다 상수버전 []를 호출했는데 값이 바뀌는 사태가 발생한다.

이런 황당한 상황을 보완하기 위해서 논리적 상수성이라는 개념이 나타났다.

 

논리적 상수성

논리적 상수성이란 상수 멤버 함수라고 해서 객체의 한 비트도 수정할 수 없는 것이 아니라 일부 몇 비트 정도는 바꿀 수 있게하고 사용자측에서 알아채지 못하면 상수 멤버 자격이 있다는 것이다.

class CTextBlock {
	char& operator[] (size_t position) const
	{
		return pText[position];
	}

	size_t length() const;

private:
	char* pText;
	size_t textLength;
	bool isLengthVaild;
};

size_t CTextBlock::length() const
{
	if (!isLengthVaild) {
		textLength = std::strlen(pText);		//여기서 에러 발생 상수 멤버에서 대입 실행
		isLengthVaild = true;
	}

	return textLength;
}

위는 당연히 상수 멤버 함수에서 대입을 실행했기 때문에 에러가 발생한다. 당연한 결과다.

해법은 mutable을 사용하는 것이다.

mutable은 비정적 데이터 멤버를 비트수준 상수성에서 벗어나도록 하는 키워드이다.

class CTextBlock {
	char& operator[] (size_t position) const
	{
		return pText[position];
	}

	size_t length() const;

private:
	char* pText;
	mutable size_t textLength;		//mutable은 어떤 상황에도 수정이 가능하다.
	mutable bool isLengthVaild;		//상수 멤버 안에서도 수정이 가능하다.
};

size_t CTextBlock::length() const
{
	if (!isLengthVaild) {
		textLength = std::strlen(pText);		//이제 에러가 나지 않는다.
		isLengthVaild = true;
	}

	return textLength;
}

 

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

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에 필요하다고 판단되는 기능(경계 검사, 접근 데이터 로깅, 자료 무결성 검증)을 추가해서 만들었다.

상수 멤버와 비상수 멤버가 똑같은 작업을 할 때 코드가 너무 중복되서 사용된다.

이렇게 되면 컴파일 시간이 늘어나고 유지보수가 힘들어지며 코드 크기도 늘어난다.

 

그렇다면 이건 어떨까?

class TextBlock {
public:
	...
	const char& operator[](std::size_t position) const
	{
		check();
		return text[position];
	}

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

private:
	void check()
	{
		//경계검사
		//접근 데이터 로깅
		//자료 무결성 검증
	}
	std::string text;
};

위에 같은 작업을 하는 것을 하나의 멤버 함수로 만들었다.

그렇다고 하더라도 return text[position]; 이 중복된다.

 

operator[]의 기능을 한 번만 구현하고 상수버전과 비상수버전에서 같이 사용하는 방법이 있다.

class TextBlock {
public:
	...
	const char& operator[](std::size_t position) const
	{
		//이전과 작업은 동일
	}

	 char& operator[](std::size_t position) 
	{
		 return const_cast<char&>(
			 static_cast<const TextBlock&>
			 (*this)[position]
			 );
	}

private:
	std::string text;
};

오우.. 중복은 없어졌지만 뭔가 const_cast니 static_cast니 *this 다양한게 생겼다.

이것부터 설명을 하자면

  • const_cast
    상수성(const)를 제거하는 캐스팅 연산자.
    상수로 간주된 값을 수정 가능한 non-const로 변환할 때 사용된다
  • static_cast
    타입 간의 변환 (ex int->float, float->int 등)
    부모 클래스와 자식 클래스 간의 안전한 형변환에 사용된다.
    특정 타입의 포인터를 다른 타입의 포인터로 변환할 수 있다.
  • *this
    현재 객체 자신을 참조하는 표현이다.
    객체 내부에서 *this를 사용하면 해당 객체의 타입에 맞는 참조값을 나타낸다.

이렇게 알면 설명이 가능하다.

 

위 함수의 역할은 비상수 operator[]가 상수 버전의 operator[]를 호출하게 하는 것이다.

*this는 위 코드에서 비상수 메서드에서 *this를 사용하고 있다.
*this의 역할이 맞게 현재 객체를 참조하며 TextBlock&(비상수에서 불렸으니 비상수이다.)를 참조한다.

이후 static_cast<const TextBlcok&>을 통해 현재 객체 TextBlock을 const TextBlock으로 형변환한다.

자 여기까지 정리하자면

static_cast<const TextBlock&>(*this)는
비상수 객체를 상수 객체로 변환하는 작업이다.

 

static_cast<const TextBlock&>(*this)[position]에서는 상수객체로 형변환이 되었기 때문에 상수 버전의 []가 호출된다.

상수 버전의 []가 호출된다는 의미는 이 결과의 반환값은 'const char&'인 것이다.

이때의 반환값은 const이기에 수정이 불가능한 값이다.

 

그리고 마지막으로 이 반환값인 const char&에서 const_cast<char&>를 통해 const를 제거한다.

이 결과로 const가 제거되며 해당 값이 수정가능하게 된다.

 

즉, 최종 결과는 비상수 버전의 operator[]을 중복없이 사용할 수 있게 된다.