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

Jangmanbo·2023년 2월 3일
0

Effective C++

목록 보기
3/33

const 객체는 외부 변경이 불가능하게 하며 이를 컴파일러가 보장한다.
또한 선행 처리자(#define)와는 달리 전역 뿐만 아니라 유효 범위의 상수를 선언할 수 있다.


포인터에서의 const

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

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

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

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

*을 기준으로 const가 오른쪽에 붙으면 포인터가 상수, 왼쪽에 붙으면 포인터가 가리키는 대상이 상수이다.


iterator(반복자)에서의 const

STL iterator는 포인터를 본떠 만들었다. (T*과 유사)

std::vector<int> vec;

const std::vector<int>::iterator iter=vec.begin();	// int* const iter

*iter=10;	// SUCCESS
++iter;		// ERROR. iter가 상수이므로 값을 바꿀 수 없음.


std::vector<int>::const_iterator cIter=vec.begin();	// const int* iter

*iter=10;	// ERROR. iter가 가리키는 대상이 상수이므로 값을 바꿀 수 없음.
++iter;		// SUCCESS.

const interator: iterator 자체가 상수
const_iterator: 가리키는 대상이 상수


함수에서의 const

함수의 리턴값이 상수

class Rational {...}

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


Rational a, b, c;

// ex.
(a*b)=c;
if (a*b=c) {}

함수의 리턴값에 대입 연산을 취하는 일을 막을 수 있다.

상수 멤버 함수

상수 객체는 상수 멤버 함수만을 호출할 수 있다.

장점

  • 클래스의 인터페이스를 이해하기 좋다.
    • 해당 클래스 객체를 변경할 수 있는 함수(=비상수 함수)와 변경할 수 없는 함수(=상수 함수)를 쉽게 구분할 수 있다.
  • 함수가 상수 객체를 사용할 수 있도록 한다.
    • 사용자 정의 클래스 객체의 경우 함수의 인자로 넘길 때 상수 객체에 대한 참조자로 넘기는 것이 효율적이다.
    • 상수 객체를 인자로 받기 위해서는 상수 멤버 함수가 필요하다.

상수 멤버 함수에는 비트수준 상수성(=물리적 상수성)과 논리적 상수성이라는 2가지 개념이 있다.

비트수준 상수성
멤버 함수가 객체의 어떤 데이터 멤버도 건드리지 않아야 const임을 인정한다.
즉 객체를 구성하는 비트 중 하나라도 바뀌면 안된다. (정적 멤버변수 제외)

하지만 비트수준 상수성을 (컴파일러 입장에서는)지키지만 (실제로는)지켜지지 않는 경우가 많다.
주로 포인터가 가리키는 대상을 수정하는 멤버 함수들은 비트수준 상수성이 지켜지지 않음에도 컴파일러의 입장에서는 데이터 멤버에 대한 대입 연산은 없기 때문에 비트수준 상수성 검사를 통과하게 된다.

class CTextBloc{
public:
	char& operator[] (std::size_t position) const
    { return pText[position]; }
    
private:
	char *pText;
}

상수 멤버 함수인 operator[]의 내부 코드에서는 pText가 바뀌지 않으므로 컴파일러는 비트수준 상수성을 지킨다고 판단한다.

그러나 operator[] 함수는 내부 데이터의 참조자를 반환하는 문제가 있다.
왜 문제인지는 아래 예시에서 보자.

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

*pc = 'J';	// cctb는 "Jello"가 된다.

상수 객체가 상수 멤버함수를 호출한 이후 값이 변해버렸다.


논리적 상수성
일부 몇 비트 정도는 바꿀 수 있으나, 사용자(=프로그래머)가 이를 알아채지만 못한다면 상수 멤버의 자격이 있다.

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) {
    	// Error. 상수 멤버함수에서 멤버변수에 대입 연산을 하기 때문
    	textLength=std::strlen(pText);
        lengthIsValid = true;
        
    }
}

위의 코드는 상수 멤버함수에서 멤버변수에 대입연산을 하기 때문에 비트수준 상수성이 지켜지지 않는다. (따라서 컴파일러가 에러를 쏟아낸다.)

그러나 CTextBlock의 상수 객체에 한해서는 length() 함수가 문제가 없어야 한다.
이런 경우에는 mutable을 사용한다.

mutable std::size_t textLength;
mutable bool lengthIsValid;

mutable로 선언한 멤버변수는 상수 멤버함수에서도 수정 가능하다.
이렇게 상수 멤버 함수는 논리적 상수성을 지키게 된다.


오버로딩

class TextBlock {

public:
	// const 유무의 차이
    // 기본 제공 타입을 반환하는 함수의 반환값을 수정할 수 없기 때문에
    // char이 아닌 char&를 반환
	const char& operator[] (std::size_t position) const
    { return text[position]; }
    
    char& operator[] (std::size_t position) const
    { return text[position]; }

private:
	std::string text;
    
}

상수함수는 반환값의 const 유무에 따라서 오버로딩이 가능하다.
반환값이 char이 아닌 char&인 이유는 반환값을 수정하는 작업 시 기본 제공 타입인 char에는 대입할 수 없기 때문이다.

TextBlock tb("Hello");
std::cout<<tb[0];	// char& operator[] 호출. 비상수 객체 읽기
tb[0]='x';			// 비상수 객체 쓰기

const TextBlock ctb("World");
std::cout<<ctb[0];	// const char& operator[] 호출. 상수 객체 읽기
ctb[0]='x';			// Error. 상수 객체 쓰기 (const char&에 대입 연산을 시도했기 때문에 에러 발생)

const char& 타입에 대입을 시도했기 때문에 에러가 발생한다.

코드 중복 피하기

만약 operator[] 함수가 단순히 특정 문자의 참조자만 반환하는 것이 아니라 다른 여러 작업을 수행한다고 가정하자.

class TextBlock {

public:
	const char& operator[] (std::size_t position) const
    { 
      // 수많은 작업 코드들...
      return text[position]; 
    }
    
    char& operator[] (std::size_t position) const
    { 
      // 수많은 작업 코드들...
      return text[position]; 
    }

private:
	std::string text;
    
}

컴파일 시간, 유지보수, 코드 중복 등 다양한 문제가 발생한다.

그럼 operator[]의 핵심 기능을 하나만 만들고 다른 버전은 이를 호출하는 식으로 구현해보자.

class TextBlock {

public:
	const char& operator[] (std::size_t position) const
    { 
      // 수많은 작업 코드들...
      return text[position]; 
    }
    
    // 비상수 operator[]가 상수 operator[]를 호출
    char& operator[] (std::size_t position) const
    { 
      return const_cast<char&>(
      	static_cast<const TextBlock&>(*this)[position]
      );
    }

private:
	std::string text;
    
}

*this를 static_cast를 통해 const 객체로 변환하여 operator[]함수를 호출한다. 즉 상수 버전의 operator[]함수를 호출한다.
(상수 객체로 변환하지 않고 호출하면 자기 자신을 호출하게 되므로 무한 재귀호출에 빠지게 된다.)

상수 버전의 operator[]의 반환 타입은 const char&이다.
비상수 operator[]는 char&을 반환해야 하므로 const_cast를 통해 상수 객체를 비상수 객체로 캐스팅하여 리턴한다.

사실 캐스팅은 남용해서는 안된다.
그러나 두번의 캐스팅을 사용하면 안정성 유지+코드 중복도 피할 수 있다.


이렇게 코드 중복을 피하기 위해 비상수 operator[]가 상수 operator[]를 호출하는 방법을 배워보았다.
그럼 반대로 상수 operator[]가 비상수 operator[]를 호출하는 건 어떨까?

앞서 말했듯 상수 멤버함수는 객체의 논리적인 상태를 바꾸지 않겠다고 약속한 함수이다.
그런데 상수 함수가 (객체가 바뀐다는 것을 보장하지 않는)비상수 함수를 호출하면 상수 함수는 약속을 어기게 된다.



결론
1. const 객체는 컴파일러가 사용상의 에러를 잡는 데 도움이 된다.
2. 컴파일러는 비트수준 상수성을 지켜야 하지만, 사용자는 논리적 상수성을 사용하여 프로그래밍해야 한다.
3. 상수/비상수 멤버 함수가 기능적으로 동일하다면 코드 중복을 피하기 위해 비상수 함수가 상수 함수를 호출하도록 구현해야 한다.

0개의 댓글