SW 그리고 아빠 엔지니어링 중...

아는 만큼 보이고, 경험해봐야 알 수 있고, 자꾸 써야 내 것이 된다.

L C++

애증의 const 사용하기 (2)

보리남편 김 주부 2024. 5. 26. 03:32

 

💬 지난 글에 이어 const를 쓰면서 막히는 사례를 풀어가 보자.

애증의 const 사용하기 (1)

const 잘 쓰시나요?💬 나는 잘 사용하지 않는다. const를 사용하다 보면 에러가 자주 발생하고, 개발하는데 적지 않은 인터럽트가 걸린다. 이런 경험들이 반복되면서 자연스럽게 const 사용하지 않

jabdon4ny.tistory.com

 
기존에 작성되어 있던 used defined type 객체를 멤버함수의 인자로 전달하는 경우를 생각해 보자. 
 

예제
Point 객체를 foo 함수 인자에 상수 참조형으로 전달을 한 예제인데 멤버함수를 호출하면 에러가 발생한다.
#include <iostream>

class Point {
public:
    int x, y;
    int getX()  {return x;};
    int getY()  {return y;};
    void setX(int value) {x = value;};
};

void foo(const Point& p){
    p.getX(); // error
}

int main() {
  return 0;
}

 
💬 그러면 아래와 같은 흐름으로 이어진다.
'아 const 삭제하고 싶다.' 하지만 const를 한 번 제대로 써보려고 마음먹었기에 다시 에러 코드를 본다.

<source>:13:11: error: passing 'const Point' as 'this' argument discards qualifiers [-fpermissive]


상수 멤버 함수가 아니라는 이유로 에러가 뜨니 함수에 const를 붙여 상수 멤버함수로 만들자며 무지성으로 const를 붙이기 시작한다. 

#include <iostream>

class Point {
public:
    int x, y;
    int getX() const {return x;};
    int getY() const {return y;};
    void setX(int value) const {x = value;}; //error
};

void foo(const Point& p){
    p.getX();
}

 
다시 컴파일!

source>:8:35: error: assignment of member 'Point::x' in read-only object

멤버변수도 상수로 처리되는 상황에 이르자 const를 전부 지워 깔끔하게(?) 해결한다.

//코드에서 const 가 사라졌다.
#include <iostream>

class Point {
public:
    int x, y;
    int getX() {return x;};
    int getY() {return y;};
    void setX(int value) {x = value;};
};

void foo( Point& p){
    p.getX();
}

int main() {
  return 0;
}


💬 황당한 전개라고 생각할 수 있는데 실제 경험담이다. 이럼에도 문제가 없었던 이유는 동료가 리뷰할 때 const 사용유무 자체를 리뷰하지 않으며, 이 영역이 Black box이기에 고객은 모른다.

그렇다면 이 문제를 해결하려면 어떻게 해야 할까? 🤔
 

const 버전, non-const 버전을 동시에 제공

멤버 함수를 const와 non-const 버전을 동시에 제공하면 된다. 두 버전을 제공하면 어떻게 되는지 아래 예제를 통해 그 동작을 이해해 보자.
 

예제
아래 예제는 동일한 이름의 non-const 함수(1번)와 const 함수(2번)를 제공하고 그 함수를 호출하는 프로그램 코드이다.
#include <iostream>

class Test
{
public:

	void foo() { std::cout << "foo\n"; }			// 1
	void foo() const { std::cout << "foo const\n"; }	// 2
};

int main()
{
	Test t;
	const Test ct;

	t.foo();
	ct.foo();
}

 

user defined type 객체인 Test를 non-const와 const 객체로 생성한 뒤 foo 함수를 호출했을 때 동작결과를 확인해 보면

t.foo() 호출 시
1번 non-const foo()가 우선 호출이 되지만 non-const foo() 함수가 없어도 2번 const foo()로 호출이 된다.

ct.foo() 호출 시

상수 객체이므로 상수 멤버함수만 가능하기에 2번 const foo() 함수만 호출이 가능한다.

 이렇게 두 가지 버전을 제공하면 타입에 맞는 함수가 호출이 된다.

표준 내장 클래스에서도 많이 사용

표준 제공 클래스에서도 동시에 const와 non-const 함수를 제공하기에 한번 확인해 보자.

std::vector를 const와 non-const 객체로 생성하여 멤버함수를 호출해 보자.

#include <vector>

int main()
{
	std::vector<int> v1 = { 1,2,3 };
	const std::vector<int> v2 = { 1,2,3 };

	auto p1 = v1.begin(); // vector 반복자		 => iterator 반환
	auto p2 = v2.begin(); // const vector 반복자 => const_iterator 반환
}

 
만약 const begin() 함수를 제공하지 않았다면 v2.begin() 코드에서 에러를 발생시켜야 하지만 발생하지 않음을 확인할 수 있다.
 
begin() 함수가 정의된 부분을 보면 아래와 같이 const 함수와 non-const 함수를 둘 다 제공하고 있다. (iterator는 아예 클래스도 const와 non-const로 나눠놨다.)

    _NODISCARD _CONSTEXPR20 iterator begin() noexcept {
        auto& _My_data = _Mypair._Myval2;
        return iterator(_My_data._Myfirst, _STD addressof(_My_data));
    }

    _NODISCARD _CONSTEXPR20 const_iterator begin() const noexcept {
        auto& _My_data = _Mypair._Myval2;
        return const_iterator(_My_data._Myfirst, _STD addressof(_My_data));
    }

 

[ ] 연산자의 const, non-const 제공

[] 연산자의 경우, 표준에 정의되어 있는 클래스에서 const와 non-const 버전 함수, 둘 다를 제공하고 있는 것을 심심치 않게 볼 수 있다. 왜 이렇게 제공하고 있는지 그 이유를 확인해 보자.

_NODISCARD _CONSTEXPR20 _Ty& operator[](const size_type _Pos) noexcept /* strengthened */ {
    auto& _My_data = _Mypair._Myval2;
#if _CONTAINER_DEBUG_LEVEL > 0
    _STL_VERIFY(
        _Pos < static_cast<size_type>(_My_data._Mylast - _My_data._Myfirst), "vector subscript out of range");
#endif // _CONTAINER_DEBUG_LEVEL > 0

    return _My_data._Myfirst[_Pos];
}

_NODISCARD _CONSTEXPR20 const _Ty& operator[](const size_type _Pos) const noexcept /* strengthened */ {
    auto& _My_data = _Mypair._Myval2;
#if _CONTAINER_DEBUG_LEVEL > 0
    _STL_VERIFY(
        _Pos < static_cast<size_type>(_My_data._Mylast - _My_data._Myfirst), "vector subscript out of range");
#endif // _CONTAINER_DEBUG_LEVEL > 0

    return _My_data._Myfirst[_Pos];
}

 

 
우선 아래 예제는 std::vector 클래스를 약식으로 만든 미완성된 코드이며 operator []를 정의해 보자.

#include <iostream>

template<typename T> class vector
{
	T* buff;
	int size;
public:
	vector(int sz) : buff(new T[sz]), size(sz) {}
	~vector() { delete[] buff; }

	//<return type> operator[](int idx) { return buff[idx]; }
};

int main()
{
	vector<int> v(10);
	v[0] = 0;	// error
}

 

<return type> 어떻게 정의해야 할까? 🤔

T operator[](int idx) { return buff[idx]; }
T 반환 : 이렇게 되면 buff[idx]가 가진 값을 반환한 r-value이기에 '0'을 대입할 수 없다.

 

T& operator[](int idx) { return buff[idx]; }
T&반환 : buff[idx] 위치의 별명을 반환하기에 대입이 가능하다.( 실제는 'v[0] = 0' 이와 같은 코드가 된다.)

 
기본형은 준비되었다. 그럼 이제 const, non-const로 생성된 vector객체를 배열로 사용할 때 동작을 확인해 보자.

#include <iostream>

template<typename T> class vector
{
	T* buff;
	int size;
public:
	vector(int sz) : buff(new T[sz]), size(sz) {}
	~vector() { delete[] buff; }
	T& operator[](int idx) { return buff[idx]; }
};

int main()
{
	int n1 = 0, n2 =0;

	vector<int> v1(10);
	const vector<int> v2(10); 

	//          // 원하는 동작		실제 결과
	v1[0] = 0;	// O				O
	n1 = v1[0];	// O				O

	v2[0] = 0;	// X				X
	n2 = v2[0];	// O				X

}

 

const 객체에 배열 사용 시 동작결과가 맞고 틀리고를 떠나서 성공, 실패의 원인을 확인해봐야 한다.

 결과의 이유
v2[0] = 0;원하는 동작 : 상수 객체이기에 대입은 안되어야 한다.
실제 결과 : 실제로 에러가 난 이유는 operator[]에 const 멤버 함수가 없어서이다.
n2 = v2[0];원하는 동작 : 상수 객체이지만 상수 객체의 값을 변수에 대입하는 것은 가능하다.
실제 결과 : 대입 이전에 상수 멤버함수를 제공하지 않아서 사용하는 시점에 에러가 발생했다.

 
이런 오동작 현상을 바로잡기 위해 const 멤버함수로 변경해서 결과를 다시 확인해 보자.
(일반 객체에서는 const 멤버함수도 호출되기 때문에 불필요한 코드 낭비를 막기 위해 const 함수만 제공해 보자.)

#include <iostream>

template<typename T> class vector
{
	T* buff;
	int size;
public:
	vector(int sz) : buff(new T[sz]), size(sz) {}
	~vector() { delete[] buff; }

	T& operator[](int idx) const { return buff[idx]; }
};

int main()
{
	int n1 = 0;

	vector<int> v1(10);
	const vector<int> v2(10);

	//          // 원하는 동작		실제 결과
	v1[0] = 0;	// O				O
	n1 = v1[0];	// O				O

	v2[0] = 0;	// X				O
	n2 = v2[0];	// O				O		
}

 

다시 동작 결과를 확인해 보자.

 결과의 이유
v2[0] = 0;원하는 동작 : const 객체이기에 대입은 안되어야 한다.
실제 결과 : const는 buff의 포인터까지만 수정이 불가하고 buff의 data는 const가 아니기게 대입이 가능하다.
n2 = v2[0];원하는 동작/실제 결과 : 상수 객체이지만 상수 객체의 값을 변수에 대입하는 것은 가능하다.

 
이번에는 const 객체를 대입이 불가하게 하기 위해 아래와 같이 리턴 값에 const를 추가를 하자.

	const T& operator[](int idx) const { return buff[idx]; }


또 문제가 있다. 이렇게 하면 반대로 non-const 객체에 대한 대입이 불가해진다. ( 'v1[0] = 0'  // error ) 그래서 const 함수만으로는 해결이 안 되고
non-const operator [] 함수를 추가해야만 완성이 된다. 완성된 코드는 아래와 같다.

완성된 예제 코드
#include <iostream>

template<typename T> class vector
{
	T* buff;
	int size;
public:
	vector(int sz) : buff(new T[sz]), size(sz) {}
	~vector() { delete[] buff; }

	T& operator[](int idx) { return buff[idx]; }
	const T& operator[](int idx) const { return buff[idx]; }
};

int main()
{
	int n1 = 0, n2 = 0;

	vector<int> v1(10);
	const vector<int> v2(10);

	v1[0] = 0;
	n1 = v1[0];

	//v2[0] = 0;
	n2 = v2[0];
}

 
완성된 코드를 통해 왜 표준 제공 클래스에 operator [] 함수를 const와 non-const 버전 둘다를 제공하고 있는지를 확인하였다.

💬 우리가 표준 제공 클래스를 사용했을 때 const 타입으로 인해 컴파일 에러를 경험해보지 못한 것처럼 두 버전을 제공하는 하는 것은 널리 사용하고 있는 방법이기에, 메모리를 절약하고 오동작 가능성을 낮추는 애증의 const를 잘 다뤄 봤으면 한다.


explicit object parameter(deducing this) 기술 소개

C++23

 
💬 C++은 간소화와 성능 그리고 효율을 중시하는 언어이지만, C++23 이전까지는 (const 키워드의 위치가 함수 인자 바깥에 있어) 간소화가 불가하여 동일한 함수 const, non-const 버전을 둘 다 제공해야 했다. 

📜 하지만 C++23의 변화중 첫 번째로 꼽히는 기술은 explicit object parameter(deducing this)이다. 함수인자로 this를 명시적으로 기재할 수 있게 되면서 간소화의 길이 열였다.

 
우선 아래 코드로 implicit object parameter와 explicit object parameter를 비교해 보자.

class Object
{
	int value;
public:
    // => 컴파일러는 아래와 같이 코드를 변경한다.
    // => "implicit object parameter" 인 this 전달
    void f1(int n) // void f1(Object* this, int n) 
    {
        value = n; // this->value = n;
    }

    // explicit object parameter since C++23
    void f2(this Object& self, int n)
    {
        self.value = n;
    }
};

int main()
{
    Object obj;
    obj.f1(10);
    obj.f2(10);
}

 
이것으로 인해 const 키워드가 함수 인자에 들어가게 된다. 이것이 인자 안에 들어가면 const, non-const 함수를 하나의 템플릿으로 자동생성 할 수 있다. 아래 예제를 참고해 보자.

#include <iostream>

class Test
{
public:
    //C++23 이전 - implicit object parameter
    void foo() { std::cout << "foo\n"; }
    void foo() const { std::cout << "foo const\n"; }

    //C++23 이후 - explicit object parameter 지원
    void foo2(this Test& self) { std::cout << "foo2\n"; }
    void foo2(this const Test& self) { std::cout << "foo2 const\n"; }

    //C++23 이후 : const,non-const 버전의 함수를 자동 생성할 수 있다.
    //그래서 별명이 deducing this로 this가 자동 추론된다는 의미이다.
    template<typename T>
    void foo3(this T& self)  { std::cout << "foo3 or const foo3 \n"; }
};

int main()
{
    Test t;
    const Test ct;

    t.foo();
    ct.foo();

    t.foo2();
    ct.foo2();

    t.foo3();
    ct.foo3();
}

 
기존 foo() 함수가 C++23부터 foo2() 함수로 변경이 가능한데, 이런 형태는 template를 써서 foo3()처럼 하나의 함수로 const와 non-const 버전을 자동생성되게 할 수 있다.
 
💬 C++23 이전버전을 사용하는 사람들에게는 const를 더 잘 다룰 수 있기를, C++23 버전을 쓰는 사람에게는 멤버함수가 더욱더 template를 써야 하는 배경을 이해할 수 있는 계기가 되었길 바란다.
 

참고


* 본 글에 예제 코드는 코드누리 샘플코드를 활용하였습니다.
 

C++ 핵심 가이드라인 한글화 프로젝트 링크

  • F.16: "입력(in)" 매개변수는 복사 비용이 적게 드는 타입의 경우 값으로 전달하고, 그 외에는 상수 참조형으로 전달하라

C++ Core Guidelines 원문 링크

  • F.16: For “in” parameters, pass cheaply-copied types by value and others by reference to const

 

 

728x90