본문 바로가기
프로그래밍/C & C++

[C++] Const

by 별준 2021. 7. 26.

References

  • Effective C++ (항목 3)

Contents

  • Const 키워드
  • 상수 멤버 함수
  • 비트수준 상수성(bitwise constness) / 물리적 상수성(physical constness)
  • 논리적 상수성(logical constness)
  • Const에 의한 오버로딩 함수의 코드 중복 문제 해결

 

Const 키워드


const를 사용하는 이유는 아마도 (소스 코드 수준에서 사용한)const 키워드가 붙은 객체는 외부에서 변경을 불가능하게 한다는 점과 컴파일러가 이러한 제약을 놓치지 않는 다는 점입니다. 어떤 값(객체의 내용)이 불변이어야 한다는 제작자의 의도를 컴파일러 및 다른 프로그래머와 나눌 수 있는 수단이 되는 것이죠.

 

const 키워드는 아주 다양한 곳에서 사용할 수 있습니다.

클래스 바깥에서는 전역 혹은 네임스페이스 유효범위의 상수를 선언(정의)하는 데 사용할 수 있습니다(위 #define 내용 참조). 또한, 파일, 함수, 블록 유효범위에서 static으로 선언한 객체에도 const를 붙일 수 있습니다.

클래스 내부에서는 정적(static) 멤버 및 비정적 멤버 모두를 상수로 선언할 수 있습니다. 

포인터에서는 기본적으로 포인터 자체를 상수로 둘 수 있고, 포인터가 가리키는 데이터를 상수로 둘 수 있는데, 둘 다 지정할 수도 있고 아무것도 지정하지 않을 수도 있습니다.

char greeting[] = "Hello";

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

const 키워드가 *의 왼쪽에 있으면 포인터가 가리키는 대상이 상수인 반면, *의 오른쪽에 위치하는 경우에는 포인터 자체가 상수입니다. 양쪽에 const가 있으면 포인터가 가리키는 대상 및 포인터가 모두 상수라는 뜻입니다.

 

여기서 포인터가 가리키는 대상을 상수로 만들 때 const를 사용하는 스타일은 조금씩 다릅니다.

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

위 두 가지 경우는 모두 상수 Widget 객체에 대한 포인터를 매개변수로 취하고 있습니다.

 

가장 강력한 const의 용도는 바로 함수 선언에 사용하는 경우입니다.

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

 

함수 반환 값을 상수로 정해 주면, 안전성이나 효율을 포기하지 않고도 유저측의 에러 케이스를 줄이는 효과를 꽤 볼 수 있습니다. 유리수 클래스에서 operator* 함수가 선언된 것을 예로 살펴보겠습니다.

class Rational { ... };

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

operator*의 반환값이 왜 상수 객체여야 할까요?

이는 상수 객체가 아니면 유저측에서 저지를 수 있는 실수를 살펴보면 알 수 있습니다.

Rational a, b, c;
...
(a*b) = c; // 1. a*b의 결과에 operator=를 호출하는 실수
if (a*b = c) ... // 2. ==을 사용해야 하지만 =을 사용한 실수

상수 객체를 반환하도록 한다면 위와 같이 그냥 키보드를 잘못 누른 경우에 에러를 잡아주게 됩니다.

만약 두번째 케이스에서 a와 b가 기본제공 타입이었다면 에러에 걸리겠지만, 사용자 정의 타입인 경우에는 상수 객체를 반환하도록 하지 않는다면 에러가 발생하지 않게 됩니다.

 

상수 멤버 함수


멤버 함수에 붙는 const 키워드는 '해당 멤버 함수가 상수 객체에 대해 호출될 함수이다'라는 사실을 알려주는 것입니다.

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

첫번째는 클래스의 인터페이스를 이해하기 좋게 하기 위함인데, 그 클래스로 만들어진 객체를 변경할 수 있는 함수는 무엇이고, 또 변경할 수 없는 함수는 무엇인가를 유저측에 제공합니다. 두번째는 이 키워드를 통해서 상수 객체를 사용할 수 있게 하는 것인데, 이는 코드의 효율을 위해서 아주 중요한 부분입니다. C++ 프로그램의 실행 성능을 높이는 핵심 기법 중 하나가 객체 전달을 상수 객체에 대한 참조자(reference-to-const)로 진행하는 것이기 때문입니다. 그런데 이 기법이 제대로 작동하려면 상수 상태로 전달된 객체를 조작할 수 있는 const 멤버 함수, 즉 상수 멤버 함수가 준비되어 있어야 합니다.

 

참고로 const 키워드가 있고 없고의 차이만 있는 멤버 함수들은 오버로딩이 가능합니다. 이 부분을 기억하면서 아래의 클래스 예를 살펴보겠습니다.

class TextBlock {
public:
	TextBlock(std::string str)
    {
    	text = str;
    }
    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;
};

위처럼 선언된 TextBlock의 operator[]는 다음과 같이 사용할 수 있습니다.

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

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

 

여기서 operator[]는 overload해서 각 함수마다 반환 타입이 다르기 때문에, TextBlock의 상수 객체와 비상수 객체의 쓰임새는 아래처럼 다릅니다.

TextBlock tb("Hello");
std::cout << tb[0];
tb[0] = 'x';

const TextBlock ctb("World");
std::cout << ctb[0];
ctb[0] = 'x';		// 컴파일에러발생

7번째 줄의 코드에서 상수 객체에 대해서 쓰기를 진행했으므로, 예상했듯이당연히 컴파일 에러가 발생하게 됩니다.

하지만, 7번째 줄에서 발생한 에러는 순전히 operator[]의 반환 타입 때문에 생긴 것입니다. operator[]의 호출이 잘못된 것이 아니라, const char& 타입에 대입 연산을 시도했기 때문에 발생한 에러인 것입니다.

 

여기서 한 가지 더 살펴볼 것은 operator[]의 비상수 멤버는 char의 참조자(reference)를 반환한다는 것인데, 참조자를 빼고 char만을 쓰면 안됩니다. 만약에 그냥 char를 반환하도록 한다면 아래의 문장은 const가 아님에도 컴파일 되지 않습니다.

tb[0] = 'x';

이는 값에 의한 반환을 수행하는 C++의 성질 때문입니다. char 타입으로만 반환하게 되면, 위 문장에 의해서 수정되는 값은 tb.text[0]의 사본이지, tb.text[0] 자체가 아니기 때문입니다.

 

비트수준 상수성 / 논리적 상수성


어떤 멤버 함수가 상수 멤버(const)라는 것은 어떤 의미를 가지고 있을까요?

여기에는 두 가지 개념이 자리 잡고 있는데, 하나는 비트수준 상수성(bitwise constness, 다른 말로 물리적 상수성(physical constness)라고도 함)이고, 또 하나는 논리적 상수성(logical constness)입니다.

 

비트수준 상수성은 어떤 멤버 함수가 그 객체의 어떤 데이터 멤버도 건드리지 않아야(static 멤버는 제외), 그 멤버 함수가 'const'라는 것을 인정하는 개념입니다. 즉, 그 객체를 구성하는 비트들 중 어떤 것도 바꾸면 안 된다는 것이죠. 비트수준의 상수성을 사용하면 상수성 위반을 발견하는 데 큰 문제가 없습니다. 컴파일러는 데이터 멤버에 대해 대입 연산이 수행되었는지만 확인하면 되기 때문이죠. 사실 C++에서 정의하고 있는 상수성이 비트수준 상수성입니다. 그리고 상수 멤버 함수는 그 함수가 호출된 객체의 어떤 비정적 멤버도 수정할 수 없게 되어 있습니다.

 

그런데, const를 사용해서 상수 멤버 함수로 정의했음에도 불구하고, 이 비트수준 상수성을 피해가는 멤버 함수들이 존재합니다. 어떤 포인터가 가리키는 대상을 수정하는 멤버 함수들이 이런 경우에 포함됩니다.

아래의 예시로 살펴보겠습니다.

class CTextBlock {
public:
	CTextBlock(std::string str) {
    	int length = str.length();
		pText = (char*)malloc((sizeof(char)*length) + 1);
        
        for(int i = 0; i < length; i++)
        	pText[i] = str[i];
        pText[length] = '\0';
	}

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

private:
	char* pText;
};

위 CTextBlock 클래스에는 operator[]가 상수 멤버 함수로 선언되어 있습니다. 이 함수는 부적절한 함수인데, 해당 객체의 내부 데이터에 대한 참조자를 반환하고 있습니다. operator[]의 내부 코드만 보면 pText의 값은 건드리지 않는 다는 것은 확실합니다. 때문에 컴파일러가 이 코드만 보면 에러를 발생하지 않겠죠. 어쨋든 비트수준 상수성을 지키고 있고, 컴파일러는 아무런 문제점을 찾지 못햇기 때문입니다.

하지만, 이로 인해서 아래와 같은 문제가 발생할 수 있습니다.

const CTextBlock cctb("Hello");
char *pc = &cctb[0];	// operator[] 상수 멤버 함수를 호출하여 cctb의 내부 데이터의 포인터를 얻음
std::cout << pc;

*pc = 'J';

std::cout << pc;

상수 객체를 만들어 놓고 상수 멤버 함수를 호출했는데, pc를 통해서 값이 변경되는 일이 발생한 것입니다.

 

논리적 상수성이란 개념은 위와 같은 상황을 보완하는 대체 개념으로 나오게 되었습니다.

이 개념은 상수 멤버 함수라고 하더라도 객체의 한 비트도 수정할 수 없는 것이 아니라 일부 몇 비트 정도는 바꿀 수 있지만, 그것을 사용자측에서 알아채지 못하게만 한다면 상수 멤버의 자격을 가진다는 것입니다.

아래의 예시를 살펴보겠습니다. 위의 예시에서 사용자가 요구할 때마다 문장의 길이를 반환하는 함수가 추가되었습니다.

class CTextBlock {
public:
	...
	std::size_t CTextBlock::length() const
	{
		if (!lengthIsValid) {
			textlength = std::strlen(pText);	// 에러 발생.
			lengthIsValid = true;		// 이 두 변수에 값 대입 불가
		}

		return textlength;
	}

private:
	char* pText;
	std::size_t textlength;
	bool lengthIsValid;
};

위 예시에서 length 함수는 당연히 비트수준 상수성과 거리가 멀게 구현되었습니다. textlength와 lengthIsValid는 당연히 상수 멤버 함수에서는 textlength와 lengthIsValid가 변경될 수 없고, 컴파일러에 의해서 에러가 발생됩니다. 이런 상황에서 컴파일러를 통과하려면 아래와 같은 방법을 사용할 수 있습니다. (전체코드)

class CTextBlock {
public:
	CTextBlock(std::string str) {
		int length = str.length();
		pText = (char*)malloc(sizeof(char)*length + 1);

		for (int i = 0; i < length; i++)
			pText[i] = str[i];
		pText[length] = '\0';
	}

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

	std::size_t CTextBlock::length() const
	{
		if (!lengthIsValid) {
			textlength = std::strlen(pText);
			lengthIsValid = true;
		}

		return textlength;
	}

private:
	char* pText;
	mutable std::size_t textlength;	// 상수 멤버함수 length에서 이 값을 변경하기 위해 
	mutable bool lengthIsValid;	// mutable 키워드 추가

};

 

 

바로 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[] 함수에서 호출하면 제법 괜찮아 보이지만 이것도 함수 호출이 두 번씩 되는 코드 중복이고, return 또한 코드 중복입니다. 

이런 경우에는 const 껍데기를 캐스팅으로 날리면 가능합니다. 기본적으로 캐스팅(casting)은 썩 좋은 아이디어는 아닙니다. 심지어 책에서는 캐스팅을 하지 말라는 항목으로도 이야기하고 있습니다. 하지만 코드 중복도 꽤 큰 문제가 될 수 있습니다.

 

지금 operator[]의 상수 버전과 비상수 버전을 비교하면 둘의 기능이 정확히 똑같습니다. 단지 다른 점이 있다면 반환 타입에 const 키워드가 붙어있다는 것뿐입니다. 따라서, 여기서는 캐스팅을 써서 반환 타입으로부터 const를 없애더라도 안전합니다. 그 이유는 비상수 operator[] 함수를 호출하는 쪽이라면 그 호출부에는 비상수 객체가 우선적으로 들어 있을 것이 분명하기 때문입니다.

따라서 결론은 이렇습니다.

캐스팅이 필요하기는 하지만, 안정성을 유지하면서 코드 중복을 피하는 방법으로 비상수 operator[]가 상수 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&>(
            	static_cast<const TextBlock&>
                	(*this)[position]
            );
    }

private:
	std::string text;
}

상수 버전의 oeprator[]를 살펴보면, 다음과 같습니다.

이 함수가 호출되면, *this 타입에 const를 붙여서 상수 버전의 operator[]를 호출하고, 그 반환 값에 const_cast를 통해서 const를 떼어내어 최종값을 반환합니다. 

보다시피 캐스팅이 총 두 번 되어 있습니다. 두 개의 캐스팅 중에 첫 번째는 상수 버전의 operator[]를 호출하기 위해 *this(비상수객체)에 const를 붙이는 캐스팅이고, 두 번재는 상수 operator[]의 반환 값에서 const를 떼어내는 캐스팅입니다.

 

무언가 문법이 조금 이상해보이기도 하지만, 코드 중복을 피하는 효과를 얻을 수 있습니다. 보기에 예쁘지는 않지만, 비상수 멤버 함수의 구현에 동일한 기능을 하는 상수 멤버 함수가 있을 때 중복을 줄이는 기법은 알아둘 가치가 있는 것 같습니다.

 

혹시 앞의 방법을 뒤집어서, 상수 버전이 비상수 버전을 호출하는 것은 올바른 방법이 아닙니다. 상수 멤버 함수는 해당 객체의 논리적인 상태를 바꾸지 않는다고 컴파일러와 약속한 것인 반면에, 비상수 멤버 함수는 그렇지 않습니다. 따라서, 상수 멤버에서 비상수 멤버를 호출하게 된다면, 수정하지 않는다는 약속을 깨버리는 것이 되고, 그 객체는 변경될 위험에 빠질 수 있습니다. 그렇기 때문에 상수 멤버 함수에서 비상수 멤버를 호출하는 것은 잘못된 것이라고 할 수 있습니다.

댓글