
첨자 연산자 오버로딩
만약 클래스의 멤버변수로 배열이 있다고 가정해보고 element에 접근하여 값을 수정한다고 해보자
class Foo
{
public:
private:
int testArr[10]{};
};
만약 위와 같은 상태라면 배열이 private:에 있기 때문에 접근할 방법이 존재하지 않는다
가장 일반적인 방법은 Getter/Setter를 만들어 사용하는 방법이다
class Foo
{
public:
void SetItem(int index, int value) { testArr[index] = value; }
private:
int testArr[10]{};
};
int main()
{
Foo f1{};
f1.SetItem(3, 4);
return 0;
}
이렇게 사용하면 f1.SetItem(3, 4);와 같이 의미가 명확하게 전달되지 않는다 (index 3에 4를 넣으라는건지, 3을 index 4에 넣으라는건지..)
혹은 배열 포인터를 return하여 operator[]를 사용하는 방법도 있다
class Foo
{
public:
int* GetTestArr()
{
return testArr;
}
private:
int testArr[10]{};
};
int main()
{
Foo f1{};
f1.GetTestArr()[0] = 10;
return 0;
}
기능은 잘 동작하지만 문법적으로 조금 어색한 부분이 있을 수 있다 (f1.GetTestArr()[])
또한 private: 멤버 변수로 되어있는 데이터의 주소를 노출하기 때문에 캡슐화 위반이 될 수 있으며 사용법이 직관적이지 않다
이럴때 operator[]를 오버로딩하여 위 문제들을 해결할 수 있다
operator[]를 오버로딩 할때는 항상 하나의 매개변수를 가지며 멤버함수로 오버로딩 해야한다
class Foo
{
public:
int& operator[](int index) //매개변수가 1개인 멤버함수로 연산자 오버로딩
{
return testArr[index];
}
private:
int testArr[10]{};
};
int main()
{
Foo f1{};
f1[0] = 100; //오버로딩한 연산자를 사용하여 객체에 바로 []연산자 적용
return 0;
}
문법적으로 쉽고 이해하기가 더 쉬워졌다
C++23부터는 여러개의 매개변수를 받는 operator[] 오버로딩이 지원된다 ex) arr[row][col]
그렇다면 왜 operator[] 오버로딩은 참조를 return할까?
f1[0] = 100
여기서 f1[0]은 lvalue여야 한다, operator[]의 결과는 lvalue로 사용될 수 있어야 한다 (배열의 element 수정), 이때 참조는 항상 lvalue이기 때문에(메모리 주소가 있는 변수의 참조이기 때문) 컴파일러는 lvalue를 사용하고 있다는것을 알 수 있다
값으로 return하게 되면 임시객체가 나와 rvalue가 된다
또한 실제 배열의 element가 수정되어야 하기 때문에 참조로 반환해야 한다
이러한 operator[] 오버로딩은 멤버 데이터를 수정하는것이기 때문에 non-const 객체에서만 호출할 수 있다
const Foo f1{};
f1[0] = 100; //error!
따라서 operator[] 오버로딩은 const와 non-const를 상황에 맞게 정의해야 한다
class Foo
{
public:
int& operator[](int index)
{
return testArr[index];
}
const int& operator[](int index) const //반환값과 함수를 둘 다 const로 처리
{
return testArr[index]; //멤버 데이터 수정이 있어선 안됨
}
private:
int testArr[10]{};
};
int main()
{
const Foo f1{}; //const 객체
std::cout << f1[0]; //const객체에서 const operator[]를 호출함
//const int&를 return하기 때문에 배열의 element 할당이 불가능하다
return 0;
}
이렇게 const버전과 non-const 버전을 둘 다 정의하게 된다면 중복 코드가 발생하게 된다
non-const 함수가 const 함수를 호출하고 const_cast로 const를 제거하는 방법을 사용할 수 있다
int& operator[](int index)
{
return const_cast<int&>(std::as_const(*this)[index]);
}
const int& operator[](int index) const
{
return testArr[index];
}
std::as_const()는 전달된 객체를 const처럼 다룰 때 사용한다 (C++17 STL)
std::as_const(*this);로 자기 자신 객체를 const처럼 바꾸고 const operator[]를 호출한 뒤 const_cast<int&>로 const를 제거한 방식이다
위 방식은 C++23에서 명시적 객체 매개변수인 self와 보편참조인 auto&&를 활용하여 쉽게 처리가 가능하다
class IntList
{
private:
int m_list[10]{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
public:
//여기서 self는 함수를 호출한 객체를 나타낸다
//auto&&는 현재 상태를 그대로 반영하여 타입을 추론한다는 의미이다 (const면 const, non-const면 non-const, lvalue면 lvalue
//this auto&& self는 this포인터가 가리키는 객체를 명시적으로 선언하는 방식이다 (그냥 암시적으로 들어오는 this는 타입추론이 안되기 떄문에 auto&&를 이용한다)
auto&& operator[](this auto&& self, int index)
{
return self.m_list[index];
}
};
int main()
{
IntList list{};
list[2] = 3; //non-const이기 때문에 auto&&가 non-const로 되어 가능
const IntList clist{};
// clist[2] = 3; // const이기 때문에 auto&&가 const로 되어 수정이 불가능
return 0;
}
operator[]를 오버로딩할 때 index valid check를 해주어 안정성을 높일 수 있다
class Foo
{
public:
int& operator[](int index)
{
//index valid check
assert(index >= 0 && static_cast<size_t>(index) < std::size(testArr));
return testArr[index];
}
private:
int testArr[10]{};
};
int main()
{
Foo f1{};
std::cout << f1[100]; //runtime error!
return 0;
}
물론 assert대신에 if()나 예외 처리로 변경도 가능하다
객체에 대한 포인터에 operator[]를 사용하면 안된다
Foo* f1{ new Foo{} };
f1[2]; //이는 Foo배열에서 index 2에 접근하려고 인식함, 사용하면 안된다
delete f1;
이를 사용하려면 역참조 후 사용해야한다
(*f1)[2]; //ok
괄호 연산자 오버로딩
operator() 오버로딩은 매개변수의 타입뿐 아니라 개수도 변경이 가능하다
operator() 오버로딩은 멤버 함수로 구현되어야 한다
class Foo
{
public:
int& operator()(int row, int col); //non-const 객체용
int operator()(int row, int col) const; //const 객체용
private:
int testArr[10][10]{};
};
int& Foo::operator()(int row, int col)
{
return testArr[row][col];
}
int Foo::operator()(int row, int col) const
{
return testArr[row][col];
}
int main()
{
Foo f1{};
f1(2, 3) = 1; //ok
const Foo f2{};
f2(3, 4) = 2; //error
return 0;
}
마찬가지로 const용 오버로딩 함수는 값으로 return하고 non-const용 오버로딩 함수는 할당 가능하도록 참조타입을 반환한다
operator() 오버로딩이기 때문에 매개변수의 개수를 원하는대로 조절할 수 있어 다음과 같은 2차원 배열에도 접근이 가능해진다
개수를 조절할 수 있기 때문에 매개변수가 아예 없는 operator() 오버로딩도 가능하다
void operator()();
void Foo::operator()()
{
std::cout << "Foo::operator() called" << std::endl;
}
operator()는 굉장히 유연하기 때문에 다양한 용도로 사용할 수 있구나 라고 생각할 수 있지만 다양한 용도로 사용하는걸 절대 권장하지 않는다 (해당 연산자가 무엇을 하는지 전혀 알 수 없기 때문에 혼란이 발생할 수 있다)
operator()는 일반적으로 함수처럼 작동하는 클래스인 functor나 함수 객체를 구현하기 위해 오버로딩 한다
functor는 결국 함수처럼 작동하는 클래스이기 때문에 멤버변수를 가질 수 있다는 장점이 있다, 그리고 필요한 만큼 여러개의 개별 functor 객체를 인스턴스화 해서 다양하게 사용할 수 있다는 장점도 있다, functor에는 다른 멤버 함수도 만들어 사용할 수 있다
간단한 functor를 구현해보자
class TestFunctor
{
public:
int operator()(int i)
{
return functorVal += i;
}
private:
int functorVal{};
};
int main()
{
TestFunctor tf{};
tf(10);
tf(3);
//13
return 0;
}
typecast 오버로딩
C++ 컴파일러는 이미 내장된 데이터 타입간의 변환 방법을 알고 있기 때문에 암시적, 명시적 캐스팅이 가능하다
단, 사용자 정의 클래스 타입으로의 변환은 어떻게 해야 하는지 모른다
따라서 우리는 변환 생성자를 이용하여 사용자 정의 클래스 타입으로의 변환을 구현하였다, 근데 그렇다면 생성자를 수정할 수 없는 클래스에서는 어떻게 처리해야 할까?
이럴때 바로 타입 변환 연산자 오버로딩을 사용한다 (typecast 오버로딩)
class Foo
{
public:
Foo(int inValue = 0) : value(inValue) {}
//typecast 오버로딩, operator와 원하는 타입()을 작성한다, 이때 const로 선언하여 const객체에서도 동작할 수 있게 해주는게 좋다
operator int() const { return value; }
private:
int value{};
};
void TestFoo(int testValue)
{
std::cout << testValue;
}
int main()
{
Foo f1{ 10 };
TestFoo(f1); //int매개변수에 Foo클래스 타입 객체가 들어갈 수 있다
return 0;
}
typecast오버로딩 함수는 non-static 멤버 함수여야 하고 const객체에서 사용할 수 있도록 const로 선언하는게 좋다
typecast오버로딩 함수는 명시적인 매개변수를 가지지 않는다, *this포인터는 존재한다
return타입을 선언하지 않는다 (변환에 int()라고 사용하기 때문에 중복임)
TestFoo(static_cast<int>(f1));
또한 static_cast<>()로 명시적으로 호출도 가능해진다 (원래는 안됨(논리적으로 말이 안되기 때문))
다음과 같이 클래스 타입끼리의 변환도 가능하다
class Foo
{
public:
Foo(int inValue = 0) : value(inValue) {}
operator int() const { return value; }
int GetFooValue() const { return value; }
private:
int value{};
};
class Bar
{
public:
operator Foo() const { return Foo{ barVal }; }
private:
int barVal{};
};
void TestFunc(Foo InFoo)
{
std::cout << InFoo.GetFooValue();
}
int main()
{
Bar b1{};
TestFunc(b1);
return 0;
}
우리가 생성자에 explicit 키워드를 적용하여 암시적 변환을 방지한 것 처럼 typecast 오버로딩에도 explicit을 적용하여 암시적 변환을 방지할 수 있다 (명시적 변환이나, 직접 초기화 (괄호 or 중괄호)로만 호출될 수 있다, 복사 초기화는 X)
class Foo
{
public:
Foo(int inValue = 0) : value(inValue) {}
operator int() const { return value; }
int GetFooValue() const { return value; }
private:
int value{};
};
class Bar
{
public:
//typecast 오버로딩 함수를 explicit으로 선언
explicit operator Foo() const { return Foo{ barVal }; }
private:
int barVal{};
};
void TestFunc(Foo InFoo)
{
std::cout << InFoo.GetFooValue();
}
int main()
{
Bar b1{};
TestFunc(b1); //error, 암시적 변환이 불가능해짐
//static_cast<Foo>(b1);으로 처리는 가능하다
Foo f1 = b1; //error
Foo f2(b1); //ok
Foo f3{ b1 }; //ok
return 0;
}
그렇다면 타입 변환 생성자와 타입 변환 오버로딩은 언제 무엇을 사용해야 할까?
타입 변환 생성자는 A타입으로부터 B타입 객체를 어떻게 생성할 지 정의하는 멤버함수이고 타입 변환 오버로딩은 A타입객체를 B타입으로 어떻게 변환할지를 정의하는 멤버함수이다
결국 차이점은 캐스팅의 책임을 어느 클래스가 갖느냐이다
일반적으로는 타입 변환을 위해서는 타입 변환 오버로딩보다 타입 변환 생성자를 권장한다, 다른 클래스에 의존하지 않고 변환 될 클래스 내부에서 책임을 지는게 더 좋은 설계라고 생각한다
그렇다면 타입 변환 오버로딩을 사용해야할 케이스는 어떤 케이스가 있을까?
기본 타입으로 변환 (int, float 등)
기본 타입에는 생성자 추가가 불가능하기 때문에 타입 변환 오버로딩을 사용해야 한다
참조나 const &를 return하는 변환을 제공할 떄
변환 생성자 정의가 불가능 할 때
클래스 헤더 include를 하지 않을 때
std::string_view에서 std::string으로의 변환은 변환 생성자 방식을 사용한다, 이때 string헤더는 string_view헤더를 포함해야 한다, 하지만 std::string에서 std::string_view로의 변환은 타입 변환 연산자 오버로딩 방식을 사용한다 (헤더가 필요 없음)
만약 std::string에서 std::string_view의 변환을 변환 생성자 방식으로 사용했다면 마찬가지로 std::string_view에도 std::string 헤더가 포함되어야 하기 때문에 std::string과 std::string_view는 서로 순환 참조가 된다
동일한 변환에 대해서 타입 변환 생성자와 타입 변환 오버로딩을 둘 다 정의하게 되면 컴파일러의 입장에서 모호하기 때문에 컴파일 에러가 발생할 수 있으니 중복 정의는 피해야한다
할당 연산자 오버로딩
operator=인 복사 할당 연산자는 한 객체의 값을 이미 존재하는 다른 객체로 복사하는데 사용된다
복사 생성자는 복사가 일어나기 전에 새로운 객체가 생성되어야 하는 경우에 사용한다
Foo f1{ f2 };
Foo f3 = f1;
TestFunc(f1)l //함수에 값으로 전달될 때 임시객체가 복사 생성됨
할당 연산자는 복사가 일어나기 전에 새로운 객체가 생성될 필요가 없다, 이미 존재하는 객체에 넣는것이다
Foo f1{};
f1 = f2;
복사 할당 연산자는 반드시 멤버 함수로 오버로딩 해야한다
class Foo
{
public:
Foo(int inValue = 0) : value(inValue) {}
//자기 자신 클래스&를 return하고 매개변수로 자기 자신 클래스 타입을 받는다
Foo& operator=(const Foo& other);
private:
int value{};
};
Foo& Foo::operator=(const Foo& other)
{
//인자로 받은 같은 클래스 타입의 데이터를 전부 복사
value = other.value;
return *this; //*this로 자기 자신을 참조로 return한다
}
int main()
{
Foo f1{ 100 };
Foo f2{ 200 };
f1 = f2; //operator=로 복사 할당 연산자 사용 가능
return 0;
}
이때 chain을 하기 위해서 자기 자신 클래스 타입의 참조를 반환한다
f1 = f2 = f3;
//f2.operator=(f3)가 먼저 되고 그 다음에 f1.operator=(f2)가 된다
self-assignment로 인한 문제
C++은 자기 할당을 허용한다
Foo f1{ 100 };
Foo f2{ 200 };
f1 = f1;
자기 할당은 아무런 영향을 주지않고 거의 필요가 없지만 동적 메모리 할당에서의 자기할당은 매우 위험할 수 있다
class Foo
{
public:
Foo(int inValue)
{
valuePtr = new int(inValue); //동적할당
}
~Foo()
{
delete valuePtr;
}
Foo& operator=(const Foo& other);
private:
int* valuePtr{};
};
Foo& Foo::operator=(const Foo& other)
{
if (valuePtr)
{
delete valuePtr; //이미 값이 존재한다면 기존에 동적할당한 메모리 delete
}
valuePtr = nullptr;
valuePtr = new int(*(other.valuePtr)); //이미 해제된 메모리에 접근하는 형태가 됨
return *this;
}
int main()
{
Foo f1{ 100 };
f1 = f1;
return 0;
}
this와 인자로 들어온 other는 같은 객체를 가리키고 있기 때문에 operator=()에서 valuePtr = new int((other.valuePtr));을 할 때는 이미 valuePtr이 delete되었기 때문에 해제된 메모리에 접근하는 형태가 되어 버려 굉장히 위험해진다
따라서 자기할당이 발생을 했을때를 체크해서 로직을 설계해야한다
Foo& Foo::operator=(const Foo& other)
{
if (this == &other) //자기 할당인지 체크
{
return *this; //early return
}
if (valuePtr)
{
delete valuePtr;
}
valuePtr = nullptr;
valuePtr = new int(*(other.valuePtr));
return *this;
}
이렇게 자기할당을 체크하는 부분을 self assignment guard라고 한다
프로그래머가 직접 복사 할당 연산자를 구현하지 않으면 컴파일러는 암시적으로 public 복사 할당 연산자를 제공한다 (멤버별 할당을 알아서 해줌, 암시적 복사 생성자와 거의 동일함)
이때 암시적 복사 할당 연산자를 사용하고 싶지 않다면 =delete 키워드를 이용하거나 private:에 선언하여 막을 수 있다
Foo& operator=(const Foo& other) = delete;
Foo f1{ 100 };
f1 = f1; //error
컴파일러는 클래스에 const 멤버가 조재한다면 암시적으로 operator=를 delete로 정의한다 (const멤버는 수정되어선 안되기 때문)