3) const 에 대해서 ( 사용 권장 )
char greeting[] = "Hello";
char* p = greeting; // 비상수 포인터, 비상수 데이터 -> 즉, 비상수 데이터를 가리키는 비상수 포인터
const char* p = greeting; // 비상수 포인터, 상수 데이터 -> 즉, 상수 데이터를 가리키는 비상수 포인터
char* const p = greeting; // 상수 포인터, 비상수 데이터 -> 즉, 비상수 데이터를 가리키는 상수 포인터
const char* const p = greeting; // 상수 포인터, 상수 데이터 -> 즉, 상수 데이터를 가리키는 상수 포인터
1) const 키워드가 *의 왼쪽에 있으면 '포인터가 가리키는 대상'이 상수,
2) const가 *의 오른쪽에 있으면 '포인터 자체가 상수'이다.
1) 의 의미는 포인터가 가리키는 대상에 대한 변경을 허용하지 않겠다는 것이며,
반대로 2)의 의미는 포인터 자체에 대한 변경을 허용하지 않겠다는 의미이다.
여기서 중요한 것은 const의 의미가 '*' 의 위치를 중심으로 바뀐다는 것이다.
즉, 아래의 코드에서 두 매개변수는 같은 타입이다.
void f1(const Widget *pw); // 상수 데이터에 대한 일반 포인터
void f2(Widget const *pw); // 상수 Widget 자료형을 가리키는 포인터
Why?
다시 한 번 잘 생각해보자.
const의 의미가 '*' 의 위치를 중심으로 바뀐다.
바로 위의 예시 코드에서 const 키워드는 '*' 문자를 중심으로 위치가 바뀌었나?
아니다. 똑같이 '*' 문자의 앞쪽, 왼쪽에 존재하고 있다.
단순히 Widget 이라는 자료형의 앞쪽이냐 뒤쪽이냐는 차이밖에 존재하지 않는다.
그러므로 두 문장은 모두 "상수에 대한 포인터"라는 의미로, 완전히 일치한다고 해석해도 상관없다.
단순히 이것을 작성하는 프로래그래머의 코딩 스타일에 달려있기 때문에, 두 형식 모두 알고 있는 편이 좋다.
( 참고: const 키워드에 대한 답변 링크 )
참고로 STL의 Iterator 역시 포인터와 유사하므로 const 키워드를 붙여서 사용할 수 있다.
또한 const_iterator 역시 따로 존재한다.
std::vector<int> vec;
// ...
const std::vector<int>::iterator iter = vec.begin(); // iter는 T* const 처럼 동작합니다.
// == std::vector<int>::iterator const iter = vec.begin() -> const 키워드를 어디에 붙여도 iterator 자체가 상수가 된다.
*iter = 10; // OK, iter가 가리키는 대상을 변경한다.
++iter; // 에러! iter는 상수
std::vector<int>::const_iterator cIter = vec.begin(); // cIter는 const T* 처럼 동작합니다.
*cIter = 10; // 에러! cIter가 가리키는 대상이 상수
++cIter; // OK, cIter를 변경한다.
위의 코드를 해석하자면,
먼저 위쪽 const iterator 는 '비상수 데이터를 가리키는 상수 포인터' 처럼 동작한다. ( ex : char* const p = greeting; )
즉, 자신이 누구를 가리키는 지에 대한 변경( ++iter; )은 불가능하지만 가리키는 대상 자체에 대한 변경 ( *iter = 10; ) 은 가능하다.
반대로 아래쪽의 const_iterator 는 '상수 데이터를 가리키는 비상수 포인터' 처럼 동작한다. ( ex : const char* p = greeting; )
즉, 자신이 가리키는 대상 자체에 대한 변경( *cIter = 10; )은 불가능하지만 자신이 누구를 가리키는 지에 대한 변경 ( ++cIter; ) 은 가능하다.
그리고 당연히 둘 다 적용시키는 것도 가능하다.
std::vector<int> vec;
vec.push_back(-999);
vec.push_back(20);
const std::vector<int>::const_iterator iter = vec.begin(); // iter는 const T* const 처럼 동작합니다.
*iter = 10; // 에러! iter가 가리키는 대상은 상수
++iter; // 에러! iter는 상수
cout << *iter << endl;
const 키워드는 함수 선언에서 가장 큰 효과를 보여준다.
사용 법은 크게 3가지가 존재하며, 모두 중요하므로 반드시 익히고 넘어가자.
1) 함수 반환 값
const Rational operator*(const Rational& lhs, const Rational* rhs);
함수의 반환 값에 대한 상수화
→ 이게 왜 필요할까?
#include <iostream>
using namespace std;
class Rational {
...
};
Rational operator*(const Rational& lhs, const Rational& rhs)
{
...
}
int main()
{
Rational a, b, c;
if ((a * b) = c) // == 으로 비교하려 했지만 실수로 = 으로 대입해버림
{
cout << "성공적으로 진입했습니다.\n";
}
return 0;
}
원본 코드
#include <iostream>
using namespace std;
class Rational {
private:
int private_data;
public:
int public_data;
Rational() :public_data(0), private_data(0) {}
Rational(int n1) : public_data(n1), private_data(0) {}
Rational(int n1, int n2) : public_data(n1), private_data(n2) {}
bool operator=(Rational& rhs)
{
// 프로그래머의 실수로 진입하게 되는 함수
if (rhs.private_data < NULL || rhs.public_data < NULL)
return false;
this->private_data = rhs.private_data;
this->public_data = rhs.public_data;
return true;
}
bool operator==(Rational& rhs)
{
// 프로그래머의 원래 의도로 진입했어야할 함수
bool result = this->private_data == rhs.private_data;
result *= public_data = rhs.public_data;
return result;
}
};
Rational operator*(const Rational& lhs, const Rational& rhs)
{
Rational r;
r.public_data = lhs.public_data * rhs.public_data;
return r;
}
int main()
{
Rational a(10), b(2, 4), c;
if ((a * b) = c) // == 으로 비교하려 했지만 실수로 = 으로 대입해버림
{
cout << "성공적으로 진입했습니다.\n";
}
return 0;
}
Rational a(10), b(2, 4), c;
if ((a * b) = c) // == 으로 비교하려 했지만 실수로 = 으로 대입해버림
{
cout << "성공적으로 진입했습니다.\n";
}
자세히 보면 a * b 라는 연산 결과를 == 으로 비교하는게 아니라 = 으로 c를 대입하고 있다.
" 누가 이런 코드를 작성해요? "
-> 그렇다. 이런 코드는 작성할 이유가 전혀 없다.
엄연히 작성한 프로그래머의 실수라고 볼 수 있다.
그런데 중요한 것은 컴파일러도 이것을 실수라고 판단하고 에러를 띄어줄까?
해당 자료형이 기본 제공 타입의 자료형이었다면 에러를 띄우겠지만, 사용자 정의 형식이라면 그렇지 않다.
즉, 위와 같은 어처구니 없는 실수를 찾으려면 프로그래머가 직접 찾아야한다.
그렇기 때문에,
const Rational operator*(const Rational& lhs, const Rational* rhs);
반환값 자체를 상수화 시켜서 위와 같이 어처구니 없는 실수를 방지할 수 있다는 것이다.
2) 상수 매개변수
Rational operator*(const Rational& lhs, const Rational* rhs);
매개 변수에 대한 변경을 허용하지 않겠다는 의미이며, 가능하면 남용하는 것을 권장한다.
( 일단 const 매개 변수로 선언하고 필요에 따라서 const 키워드를 지우는 방식도 생각해볼 수 있다. )
3) 상수 멤버 함수
#include <iostream>
#include <vector>
using namespace std;
class MathTool {
public:
int nomal_func(int a, int b) {
return a + b;
}
int const_func(int a, int b) const{
return a + b;
}
};
int main()
{
MathTool nomal_mt; // 비상수 객체
const MathTool const_mt; // 상수 객체
cout << const_mt.const_func(1, 2) << endl; // OK, 상수 객체의 상수 멤버 함수 호출
cout << nomal_mt.const_func(1, 2) << endl; // OK, 비상수 객체의 상수 멤버 함수 호출
cout << const_mt.nomal_func(1, 2) << endl; // Error! 상수 객체의 일반 멤버 함수 호출
cout << nomal_mt.nomal_func(1, 2) << endl; // OK, 비상수 객체의 일반 멤버 함수 호출
return 0;
}
함수의 이름 뒤에 const ( 정확히는 매개변수 괄호 뒤에 ) 키워드를 붙임으로서, 상수 멤버 함수를 작성할 수 있다.
위의 예제 코드에서 보다시피
비상수 객체는 상수 멤버 함수, 일반 멤버 함수 모두 호출이 가능하지만, 상수 객체는 오로지 상수 멤버 함수만 호출이 가능하다.
따라서, 멤버 함수에 붙는 const 키워드는 “해당 멤버 함수가 상수 객체에 대해 호출될 함수이다.” 라는 사실을 알려준다.
이것은 뭘 의미할까? 크게 두 가지를 말할 수 있다.
단순히 const 키워드가 유무에 따라서도 함수의 overloading이 가능하다.
#include <iostream>
#include <vector>
using namespace std;
class MathTool {
public:
int SomeProc(int a, int b) const {
cout << "called const SomeProc Func" << endl;
return a + b;
}
int SomeProc(int a, int b) {
cout << "called nomal SomeProc Func" << endl;
return a + b;
}
/*
const int SomeProc(int a, int b) { // 반환 형식만으로 구분되는 함수는 오버로드 불가
cout << "called const nomal SomeProc Func" << endl;
return a + b;
}
*/
};
int main()
{
MathTool nomal_mt;
const MathTool const_mt;
cout << nomal_mt.SomeProc(1, 2) << endl;
cout << const_mt.SomeProc(3, 4) << endl;
return 0;
}
위의 결과로 볼 수 있듯이,
비상수 객체는 (오버로딩 함수 중에서) 비상수 멤버 함수를 우선시해서 호출한다.
( 비상수 객체는 비상수 함수 및 상수 함수 모두 호출 가능하다. )
반대로 상수 객체는 상수 멤버 함수를 우선해서 호출한다.
( 사실, 상수 객체는 상수 함수밖에 호출하지 못하기 때문에 당연하다. )
class TextBlock {
public:
//const 멤버 함수
const char& operator[](std::size_t position) const{
return text[position];
}
//비const 멤버 함수
char& operator[](std::size_t position){
return text[position];
}
private:
std::string text;
}
// 상수 객체 생성
void print(const TextBlock& ctb){
std::cout << ctb[0]; // 상수 멤버 함수 operator[] 호출
}
// 비상수 vs 상수 함수
TextBlock tb("Hello");
TextBlock ctb("Hello");
std::cout << tb[0]; // (O) => 비상수 멤버 함수 호출
std::cout << ctb[0]; // (O) => 상수 멤버 함수 호출
tb[0] = 'x'; // (O) => 비상수 멤버 함수 호출
ctb[0] = 'x'; // (X) => 비상수 멤버 함수 호출
// 반환값이 const char& 이기 때문에 값 할당 안됨
위의 예제는 실제 활용할만한 형식의 코드이다.
[] 연산자를 각각 const, 일반 형식으로 오버로딩한 것이다.
여기서 하나 더 중요한 것은 char& 형식의 참조자를 반환하고, tb[0] = 'x'; 와 같이 해당 값을 변경하는 것인데,
만약 char& 가 아니라 단순히 char 라면 tb[0] = 'x'; 이 문장은 컴파일조차 불가능하다.
해당 함수가 기본 제공 타입을 반환하고, 해당 반환값에 대한 변경이 불가능하기 때문이다. ( ex 'c' = 'b' , 1 = 2 )
( 이해하기에 굉장히 많은 시간이 걸렸던 항목 )
어떤 멤버 함수가 상수 멤버 함수라는 것은 어떤 의미를 가지는가?
이것은 비트 수준 상수성 ( 물리적 상수성이라고도 한다. ) 및 논리적 상수성이라는 개념을 통해 설명이 가능하다.
1) Bitwise Constness
→ 어떤 멤버 함수가 그 객체의 어떤 데이터 멤버도 건드리지 않아야 ( static 멤버는 제외 ) 해당 함수가 const 임을 인정한다는 개념이다.
→ 즉, 해당 함수가 해당 객체를 구성하는 어떠한 비트도 변경시키면 안 된다는 것을 의미한다.
→ 컴파일러가 비교적 쉽게 위반을 발견할 수 있으며, 이것이 c++에서 정의하는 상수성이기도 하다.
그런데 이 개념만으로는 상수성을 완전하게 지킬 수 없다.
아래의 예제를 보자.
class CTextBlock {
public:
CTextBlock(const char* str)
{
pText = new char[strlen(str)];
strcpy(pText, str);
}
char& operator[](std::size_t position) const {
return pText[position];
}
void print_str() const{
cout << pText << endl;
}
private:
char* pText;
};
int main()
{
// 개발자는 변수를 허락하지 않는 의도로 상수 객체 생성
const CTextBlock cctb("Hello");
char* pc = &cctb[0];
// 하지만 변경됨
*pc = 'J';
cout << pc << endl;
cctb.print_str();
return 0;
}
분명 상수 객체인 cctb를 생성했으나, [] 함수를 통해 특정 참조자를 반환 받아서 변경이 가능해진다.
물론, 해당 멤버 함수의 반환 형식을 const char& 형식으로 변경하면 위의 상황에서도 수정이 불가능해진다.
이렇게...
...
const char& operator[](std::size_t position) const {
return pText[position];
}
...
여기서 말하고자 하는 바는,
예시에서 등장하는 멤버 함수가 부적절하다는 것이다.
...
char& operator[](std::size_t position) const {
return pText[position];
}
...
" 해당 함수( 일반 참조자를 반환하는 상수 멤버 함수 )는 상수 멤버 함수라 부를 수 있는가? "
다시 언급하자면, 상수 멤버 함수는 아래와 같은 상수 객체에서만 호출이 가능한 함수이다.
...
// 개발자는 변수를 허락하지 않는 의도로 상수 객체 생성
const CTextBlock cctb("Hello");
...
그런데 일반 참조자를 반환해서 단순히 반환값을 조작하는 것만으로 상수 객체에 대한 수정이 가능해진다.
...
char* pc = &cctb[0];
// 하지만 변경됨
*pc = 'J';
...
심지어 해당 함수에 대한 선언과 사용에 대해서 컴파일러는 어떠한 문제도 제기하지 않는다.
앞서 언급한 '비트 수준의 상수성(Bitwise Constness)'을 위반하지 않기 때문이다.
즉, 컴파일러 입장에서는 철저하게 비트 수준의 상수성이 적절한지만 검사한다.
( 이 말은 물리적 상수성이라는 개념만으로는 위의 함수가 적절한 것이 되어버린다. )
하지만 프로그래머 입장에서 보면 위의 예시는 부적절하다.
"논리적 상수성"이라는 개념이 등장한 원인이 바로 위와 같은 상황을 보완하기 위해서다.
상수 멤버 함수가 직접 혹은 상수 멤버 함수를 통해서 객체의 한 비트도 수정하지 못하게 하는 것이 아니라,
수정은 가능하지만 그것을 사용자 측에서 알지 못하게만 한다면, 해당 함수는 적절한 상수 멤버 함수라고 불릴 수 있다는 것이다.
즉, "상수 멤버 함수를 통해서 객체 값을 변경하려면 사용자가 알지 못하게 해라" 는 일종의 강제성 없는 규칙이다.
( 이 개념을 통해서, 물리적 상수성 개념에는 적절한 참조자 반환 상수 멤버 함수가 부적절하다고 말할 수 있다. )
논리적 상수성을 설명하기 위한 전형적인 상황을 보자.
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) {
textLength = std::strlen(pText); // 에러! 상수 멤버 함수 내에서 값 변경은 불가
lengthIsValid = true; // 에러!
}
return textLength;
}
CTextBlock 객체의 멤버 함수인 length() 는 상수 멤버 함수이므로 당연히 내부 필드 값에 대한 수정이 불가능하다.
하지만 프로그래머 입장에서 보았을 때,
CTextBlock 객체 자체에 대한 변경이 아니라 단순히 내부 체크용으로 사용되는 필드에 대한 수정이 불가능하다는 것은 다소 불합리하다.
해당 상황에서 사용될 수 있는 키워드가 바로 'mutable'이라는 키워드이다.
위 코드에서 단순히 두 키워드만 추가하면 된다.
class CTextBlock {
public:
...
std::size_t length() const;
private:
char *pText;
// mutable 에 주목하라!
mutable std::size_t textLength; // 바로 직전에 계산한 텍스트 길이
mutable bool lengthIsValid; // 현재 길이가 유효한가? ( mutable 키워드가 붙으면 어떠한 순간에도 수정이 가능해진다.)
};
std::size_t CTextBlock::length() const {
if (!lengthIsValid) {
textLength = std::strlen(pText); // 문제 없이 동작함.
lengthIsValid = true; // 문제 없이 동작함.
}
return textLength;
}
보다시피, 해당 상수 멤버 함수는 내부에서 객체 필드에 대한 변경이 이루어진다.
하지만 이를 사용하는 입장에서는 그것을 눈치챌 수 없다.
'물리적 상수성'이 아니라 '논리적 상수성'에 따라서 유효한(적절한) 상수 멤버 함수인 것이다.
두 개념에 대해서 다시 정리하자면,
'물리적 상수성'은 컴파일러를 통해서 강제성을 가지는 개념이지만, '논리적 상수성'은 강제성을 가지지는 않지만 반드시 준수해야 할 규칙 같은 개념이다.
( 때문에 일반 참조자 반환 상수 멤버 함수가 컴파일러는 허락하지만 부적절한 함수라고 말할 수 있다. )
최종적으로, 컴파일러는 철저하게 물리적 상수성 ( 비트 수준 상수성 ) 을 따르지만 프로그래머는 논리적 상수성을 생각해서 코딩해야한다. ( mutable )
앞서 언급했다시피, 상수 및 비상수 멤버 함수는 서로 별개로 취급하기 때문에, 단순 const 키워드 하나만으로도 오버로딩이 가능하다.
하지만 이 말은 설령 똑같은 수행을 하더라도 상수, 비상수 버전을 따로 작성해야 한다는 것을 의미하는데, 이것은 비슷한 코드가 중복될 수 있다는 의미이기도 하다.
아래의 예시를 보자.
class TextBlock {
public:
...
const char& operator[] (std::size_t position) const
{
cout << "상수 멤버함수 입니다" << endl;
... // 경계검사 코드
... // 접근 데이터 로깅 코드
... // 자료 무결성 검증 코드
return text[position];
}
char& operator[] (std::size_t position)
{
cout << "상수 멤버함수 입니다" << endl;
... // 경계검사 코드
... // 접근 데이터 로깅 코드
... // 자료 무결성 검증 코드
return text[position];
}
private:
std::string text;
};
한 눈에 보기에도 코드 중복이 예상된다. ( 모두 주석으로 처리했지만 )
가장 먼저 떠올릴 수 있는 방법은 별도의 private 멤버 함수를 선언하고 해당 중복 코드들을 한 곳으로 옮기는 것이다.
하지만 이렇게 해도 해당 함수를 호출하는 코드 자체 및 return 문은 중복된다. ( 물론 받아들일 수 있는 수준이라면 해당 방식으로 사용해도 된다. )
이 방법과는 다르게 완전히 중복을 제거하는 방법이 존재한다.
class TextBlock {
public:
...
const Char& operator[] (std::size_t position) const // 이전과 동일한 비상수 멤버함수
{
... // 경계검사 코드
... // 접근 데이터 로깅 코드
... // 자료 무결성 검증 코드
return text[position];
}
char& operator[] (std::size_t position)
{
cout << "비상수 멤버함수 입니다" << endl;
return
const_cast<char&>(
static_cast<const TextBlock&>
(*this)[position]
);
}
private:
std::string text;
};
굉장히 복잡해 보이지만 하나씩 해석해보자면,
정말 이게 필요하다고?
결과만 보자면, 코드 중복은 사라졌지만 어쩐지 더 복잡한 코드가 되어버렸다고 생각할 수 있다.
따라서 해당 방식을 실제로 사용하는 것은 전적으로 프로그래머의 판단에 달려있지만,
실제로 여러 라이브러리 등에서 자주 사용되므로 이러한 기법이 있다는 것을 알아둘 필요성은 확실하다.
위의 방식에서 총 두 번의 캐스팅이 사용되었는데, ( 물론 캐스팅 행위 자체는 지양하는 것이 옳지만, (항목 27) 코드 중복이 훨씬 위험한 요소이다. )
비상수 객체를 상수 객체로 변환하는 캐스팅 ( static_cast ) 은 단순히 안전한 타입 변환을 강제로 수행하는 것이다.
상수 객체를 비상수 객체로 변환시키는 캐스팅 ( const_cast ) 은 위험하지만,
위의 예시 상황( 두 멤버 함수가 수행하는 행위가 일치하는 상황 )이라면 안전하다.
잘 생각해보면, 2) 비상수 객체를 상수화 시켰다가 3) 해당 객체로 상수 멤버 함수를 호출하고 4) 해당 반환 값을 곧바로 비상수화 시키기 때문에 위험할 요소가 전혀 없다.
상수 멤버 함수 내부에서는 객체에 대한 변경될 가능성이 없기 때문에 ( mutable 및 static 필드 제외 ), 단순히 const 키워드를 붙였다 떼었다 하는 동작은 안전할 수 밖에 없다.
하지만 반대로 상수 멤버 함수에서 비상수 멤버 함수를 호출하는 행위는 위험하다.
비상수 멤버 함수 내부에서는 객체에 대한 변경이 가능하기 때문에,
상수 객체가 객체를 변경하지 않는다는 약속을 배신할 수 있기 때문이다.
정리하자면,
상수 / 비상수 멤버 함수의 수행 내용이 대부분 일치하는 상황에서는 코드 중복이 발생할 수 있다.
때문에 이를 완전히 제거하고 싶다면 반드시 비상수 멤버 함수가 상수 멤버 함수를 호출하는 형식으로 중복을 제거하자.
Constexpr ( 상수식 ) 대한 추가 정리
먼저 constexpr 의 등장 배경에 대해서 알아야 한다.
C++11 버전부터 등장한 키워드.
const 의 부족한 부분을 채우기 위해서 등장하였다.
→ 값이 정해지는 시점이 다르다.
cpp → obj : 컴파일 시점
obj → exe : 실행 시점
int main()
{
// 컴파일 시점에 값이 정해진다.
const int cst = 10;
constexpr int cstex = 10;
int val = 10;
// 컴파일 시점에 값이 정해진다?
const int cstVal = val;
constexpr int cstexVal = val;
return 0;
}
'constexpr' is always 'const' but 'const' is not always 'constexpr'.
→ constexpr 은 항상 const 이지만 const는 항상 constexpr 인 것은 아니다.
const 객체는 컴파일 시점 혹은 실행 시점에 값이 정해진다.
constexpr 객체는 항상 컴파일 시점에 값이 정해진다.
#include<iostream>
using namespace std;
template <int N> // 템플릿 특수화
void PrintVal()
{
cout << N << endl;
}
int main()
{
PrintVal<1>(); // 1. ok
const int n1 = 2;
PrintVal<n1>(); // 2. ok
int temp = 2;
const int n2 = temp;
PrintVal<n2>(); // 3. nok
constexpr int n3 = 2;
PrintVal<n3>(); // 4. ok
return 0;
}
1. 리터럴 : 컴파일 시점에 확정
2. const 객체 리터럴 초기화 : 컴파일 시점에 확정
3. const 객체 변수 초기화 : 실행 시점에 확정
4. constexpr 리터럴 초기화: 컴파일 시점에 확정
#include<iostream>
using namespace std;
const int retTen1()
{
return 10;
}
constexpr int retTen2()
{
return 10;
}
template <int N> // 템플릿 특수화
void PrintVal()
{
cout << N << endl;
}
int main()
{
PrintVal<retTen1()>(); // ok? nok? : nok
PrintVal<retTen2()>(); // ok? nok? : ok
return 0;
}
항목 6) 암시적으로 생성되는 함수들을 금지하기 (0) | 2021.12.05 |
---|---|
항목 5) C++가 자동으로 만들고 호출하는 함수들 (0) | 2021.12.05 |
항목 4) 객체 사용 전에 반드시 먼저 초기화하자 (0) | 2021.12.05 |
항목 2) #define 을 지양하자 (0) | 2021.08.15 |
항목 1) C++ 언어란? (0) | 2021.08.15 |
댓글 영역