본 포스팅은 교재를 학습하며 복습 노트 목적으로 요약 정리한 내용입니다.
교재 전문을 포함하고 있지 않으며 개인적인 이해를 돕기 위한 사족이 포함되어 있으므로
정확한 학습을 위해 직접 교재를 구매해 읽어보시기를 적극 권장합니다.
- 연산자 오버로딩이란?
- C++에서 제공하는 기본 타입이 아닌, 사용자 정의 타입에도 연산자를 사용할 수 있게 하는 문법입니다.
- 많은 STL 구성 요소(Component)에 사용됩니다.
- 그 이유는 컴파일러 내부에 정의되지 않은 사용자 정의 타입의 연산을 가능하게 하므로 더욱 쉽고 직관적이며 일반화된 가독성 좋은 코드를 만들기 때문입니다.
예를 들어 아래의 코드는 + 라는 연산자가 이미 컴파일러 내부에 정의되어 있기 때문에 문제 없이 실행됩니다.
#include <iostream>
using namespace std;
int main()
{
int iNumber1 = 10;
int iNumber2 = 20;
cout << iNumber1 + iNumber2 << endl;
return 0;
}
반면 아래 코드에서는 사용자가 직접 정의한 클래스를 사용하기 때문에 위와 같은 + 연산의 사용이 불가능합니다.
컴파일러 내부에 연산이 정의되어 있지 않기 때문에 컴파일러 입장에서는 프로그래머가 명시한대로 이 클래스에 대해 + 연산을 하면 뭐가 어떻게 더해지는지 알 수 없기 때문입니다.
이에 대해 자세히 알아보겠습니다.
아래 코드는 단순하게 각 연산을 수행하는 것처럼 보이지만, 사실 실행될 때 주석에 적힌 것 처럼 클래스의 멤버함수를 호출하며 이때 인자로 오른쪽에 적힌 객체를 넘겨줍니다.
p1 + p2; // => p1.operator+(p2); 와 같다.
p1 * p2; // => p1.operator*(p2); 와 같다.
p1 = p2; // => p1.operator=(p2); 와 같다.
p1 == p2; // => p1.operator==(p2); 와 같다.
p1 += p2; // => p1.operator+=(p2); 와 같다.
즉, 사용자 정의 타입(클래스 타입)의 객체에 연산자를 사용하면 컴파일러는 사용자가 미리 멤버 함수로 정의해놓은 함수를 호출합니다.
일반적인 함수의 실행 조건을 생각해보면 여기서 아주 당연한 사실을 떠올릴 수 있습니다.
'사용자가 클래스 내부에 함수를 정의해놓았다면 정상적으로 호출이 될 것이고, 함수가 존재하지 않는다면 이름이 같은 함수를 찾을 수 없으므로 컴파일러 에러가 발생할 것이다.'
그렇다면 클래스 내부에 각 연산을 담당하는 함수를 만들어주면 문제가 해결되겠군요.
컴파일러가 함수를 호출할 수 있도록 바로 클래스에 operator 멤버 함수를 추가해봅니다.
이제 컴파일러가 정상적으로 함수를 호출할 수 있게 되었습니다.
그런데 + 연산의 결과가 아닌 호출되었다는 문자열을 출력하고 있네요.
이것 역시 당연합니다.
오버로딩한 연산자는 사용자가 자신의 필요에 따라서 재정의 해둔 것이기 때문에 컴파일러는 그 내부에 진짜 + 연산이 들어있는지 단순히 문자열을 출력하고 있는지에 대해서는 전혀 신경쓰지 않습니다.
그저 프로그래머가 호출하려는 함수와 같은 이름을 가진 멤버 함수를 찾아 그대로 실행해줄 뿐입니다.
따라서 연산자를 오버로딩할 때는 아래와 같이 멤버 함수 내부에 실제로 이 연산자를 실행했을 때 일어나는 연산이 어떤 의미인지 하나하나 적어주어야 합니다.
이렇게 만들어진 연산자 오버로딩을 호출하는 방법은 2가지가 있습니다.
첫번째는 재정의한 연산자 기호를 사용하는 것으로 이 경우에는 컴파일러가 연산자 기호를 읽는 순간,
이것을 operator연산자기호(인자값) 로 자체 해석하여 같은 이름을 가진 재정의 멤버 함수를 호출하는 것입니다.
두번째는 일반 멤버함수가 그렇듯 객체의 멤버에 접근해 (.) 내부에 있는 함수명을 명시하여 컴파일러가 따로 연산 기호를 해석하지 않아도 직접 함수를 호출할 수 있도록 하는 방식입니다.
- const 멤버 함수 / 비 const 멤버 함수
const 객체는 비 const 멤버 변수를 호출할 수 없습니다.
예를 들어서, const 함수의 경우 get 함수처럼 객체의 멤버 변수를 변경하지 않는 함수의 경우에는 호출 가능합니다.
반면 set 함수의 경우에는 멤버 변수를 변경하기 때문에 호출할 수 없습니다. - 단항 연산자 오버로딩
- 오버로딩이 가능한 단항 연산자는 !, &, ~, *, +, -, ++, -- 형변환 연산자가 있습니다. - ++연산자 오버로딩
++연산자에는 전위 ++연산자와 후위 ++연산자가 있습니다. 각각 컴파일러와 약속된 함수는 전위 연산자의 경우 operator++()이고 후위 연산자는 operator++(int) 입니다.
두 연산자 모두 operator++() 멤버 함수를 호출하기 때문에, 둘을 구분하기 위해서 후위 ++ 연산자의 경우 뒤에 의미없는 더미 정수형 인자 0을 전달하기 때문에 operator++(int) 형태를 가집니다. 이는 전위와 구별하기 위한 문법적 장치이기 때문에 실제 인자로는 아무 의미가 없습니다.
const Point& operator++ ()
{
++x;
++y;
return *this;
}
const Point operator++(int)
{
Point pt(x, y); // 먼저 내부에서 복사본을 만들고, 나중에 이 복사본을 리턴
++x; // 내부 구현이기 때문에 멤버 변수는 전위 ++ 연산자를 사용해도 무방
++y;
return pt;
}
- 이 부분을 공부하면서 궁금했던 점
1. 왜 굳이 const를 사용하는지?
현재 오버로딩 함수의 선언부분을 보면 반환 타입에 대해서 const가 붙어있는 것을 알 수 있습니다.
이는 연산자 내부에서 값이 변경된 후 반환할 때, 이 반환된 값이 외부의 예측할 수 없는 요인으로 인한 접근으로 임의 변경되는 것을 막기 위함입니다.
2. 왜 굳이 예제에서 후위 연산자를 구현할 때 전위 연산자를 사용했을까?
외부에서 보기에 후위 연산자는, 사용 후에 멤버 변수 값이 변경되는 것처럼 동작해야 합니다.
그러나 결과적으로 멤버 변수가 바뀌기만 하면 되므로, 내부 구현에서 전위든 후위든 구분해서 사용할 필요가 없습니다.
또한 일반적으로 전위 연산자가 후위 연산자보다 더 효율적입니다.
후위는 전위와 달리 연산자 내부에서 값을 변경하고 그 값을 바로 반환하는 것이 아니라,
0내부적으로 복사본을 만들어 사용 전의 값을 저장하고 변경하고 난 후에 반환하는 것은 원래 값이므로 비용이 더 큽니다.
따라서, 전위 연산자를 사용하는 것이 더 빠르고 간결합니다.
내부적으로 따지면 전위 연산자를 사용한 ++x는
x = x + 1
에 가깝고,
후위 연산자를 사용한 x++는
temp = x;
x + 1;
return temp;
에 가깝습니다.
3. 전위 연산자, 후위 연산자 모두 결론적으로 최종 변경된 값은 똑같은데 왜 굳이 후위 연산자라는 것이 존재할까? 실제로 어떤 경우에 후위 연산자를 사용할까?
전위 연산자와 후위 연산자간 차이의 본질은 변화의 시점과 반환값입니다.
연산자 연산 시점 반환값 사용 목적++a (전위) 즉시 증가 후 사용 증가된 값 빠른 처리와 참조 효율 a++ (후위) 사용 후 증가 증가 전의 값 이전 값을 보존해야 할 때
전위의 경우 연산 즉시 해당 값이 증가되지만 후위의 경우 일단 증가 전의 값이 반환됩니다.
이런 특수한 속성 때문에 후위 연산자는 이전 상태의 값이 필요한, 예를 들어 반복문 안에서 이전 인덱스를 저장해둔 뒤 나중에 증가시키는 상황과, STL의 반복자에서 적절하게 사용할 수 있습니다. - 후위 연산자의 활용 1
for (int i = 0; i < 10; i++)
{
arr[i] = i; // i는 증가 전 값이여야 함 → 후위가 필요
}
- 후위 연산자의 활용 2
std::vector<int>::iterator it = vec.begin();
*it++ = 5;
이 코드의 의미: *it에 5를 저장하고, 그 후 반복자 it를 다음 위치로 이동.
→ 전위라면 순서가 변경됨!
- -- 연산자 오버로딩
-- 연산자 오버로딩은 ++연산자 오버로딩과 방법이 같습니다. - 이항 연산자 오버로딩
오버로딩 가능한 이항 연산자에는+, -, *, /, ==, !=, <, <= 등이 있습니다.
5. 전역 함수를 이용한 연산자 오버로딩
연산자 오버로딩의 종류에는 멤버 함수를 이용한 연산자 오버로딩과 전역 함수를 이용한 연산자 오버로딩이 있습니다.
일반적으로는 앞서 배운 방식인 멤버 함수를 이용한 연산자 오버로딩을 사용하지만, 멤버 함수를 이용한 연산자 오버로딩을 사용할 수 없는 경우에는 전역 함수 연산자 오버로딩을 사용합니다.
- 멤버 함수를 이용한 연산자 오버로딩을 사용할 수 없는 경우?
이항 연산이 실행될 때는 왼쪽 객체를 기준으로 오버로딩 멤버 함수가 호출되기 때문에, 이항 연산의 왼쪽 항이 연산자 오버로딩 객체가 아닌 경우에는 멤버 함수를 이용한 연산자 오버로딩을 사용할 수 없습니다.
예를 들어 컴파일러가 p1 == p2;와 같은 연산자 오버로딩을 사용한 코드를 해석하는 방식은 두 가지입니다.
1) 멤버 함수로 p1.operator == (p2);로 해석해 p1의 operator==() 멤버 함수를 호출해서 p2를 인자로 전달합니다.
2) 전역 함수로 p1.operator == (p1, p2);로 해석해 전역 함수 operator==()의 인자로 p1와 p2 객체를 각각 전달합니다.
전역 함수를 이용하면 클래스의 private 멤버에 접근할 수 없으므로 getter를 이용하거나 프렌드 함수를 이용해야 합니다.
프렌드 함수는 캡슐화 원칙을 저해하기 때문에 가능하면 게터, 세터를 사용하는 것이 좋습니다.
프렌드 (friend)
프렌드에는 함수 프렌드와 클래스 프렌드가 있습니다.
함수나 클래스를 프렌드로 지정하면 접근 제한 지정자를 무시할 수 있습니다.
즉, 모든 클래스 멤버(private, protected, public)를 사용할 수 있습니다.
1. 함수 프렌드
friend void Func();
2. 클래스 프렌드
friend class B;
6. STL에 필요한 주요 연산자 오버로딩
- 함수 호출 연산자 오버로딩(() 연산자)
함수 호출 연산자 오버로딩은 객체를 함수처럼 동작하게 하는 연산자입니다.
C++에서 Print(10)이라는 함수 호출 문장은 다음 세 가지로 해석할 수 있습니다.
1) 함수 호출 : Print가 함수 이름
2) 함수 포인터 : Print가 함수 포인터
3) 함수 객체 : Print가 함수 객체
여기서 함수 호출 연산자를 정의한 객체를 함수 객체라고 합니다.
#include <iostream>
using namespace std;
struct FuncObject
{
public:
void operator() (int arg) const
{
cout << "정수 : " << arg << endl;
}
};
int main()
{
FuncObject Print3;
Print3(10); // '함수 객체'를 사용한 정수 출력(Print3.operator(10)과 같음)
return 0;
}
아래 예제처럼 여러 인자를 받는 함수 호출 연산자를 중복할 수 있습니다.
int main()
{
FuncObejct print;
print(10); // 객체 생성 후 호출 (암시적)
print(10, 20);
print(10, 20, 30);
cout << endl;
print.operator()(10); // 객체 생성 후 호출 (명시적)
print.operator()(10, 20);
print.operator()(10, 20, 30);
cout << endl;
FuncObject()(10); // 임시 객체로 호출 (암시적)
FuncObject()(10, 20);
FuncObject()(10, 20, 30);
cout << endl;
FuncObject().operator()(10); // 임시 객체로 호출 (명시적)
return 10;
}
위 예제의 FuncObejct()처럼 클래스 이름으로 임시 객체를 생성할 수 있습니다.
임시 객체는 그 문장에서 생성되고 그 문장을 벗어나면 소멸됩니다.
그 문장에서만 임시로 필요한 객체에 사용합니다.
- 배열 인덱스 연산자 오버로딩 ([ ] 연산자)
배열에 사용하는 [ ] 연산자를 객체에서도 사용할 수 있도록 배열 인덱스 연산자 오버로딩을 할 수 있습니다.
[ ] 연산자 오버로딩은 일반적으로 많은 객체를 저장하고 관리하는 객체에 사용됩니다.
아래는 Point pt(1, 2)일때 pt[0]은 좌표의 x값인 1을 반환하고, pt[1]은 좌표의 y값인 2를 반환하게 하도록
배열 인덱스 연산자를 오버로딩한 예제입니다.
int operator[] (int idx) const
{
if (idx == 0)
return x;
else if (idx == 1)
return y;
else
throw "이럴 수는 없는 거야!";
}
[ ] 연산자 오버로딩은 일반적으로 컨테이너 객체에 사용되며 컨테이너 객체가 관리하는 내부 원소에 접근할 때 사용할 수 있습니다.
아래의 예제는 정수를 저장하는 간단한 Array 클래스에 대한 [ ] 연산자 오버로딩의 구현과 실제 사용 예시입니다.
int operator[] (int idx) const
{
return arr[idx];
}
int main()
{
.
.
.
for (int i = 0; i < arr.Size(); i++)
{
cout << ar[i] << endl; // 이때 ar[i]는 ar.operator[](i)와 같습니다.
}
}
[ ] 연산자 오버로딩은 arr[i] = 20과 같은 쓰기 연산도 가능해야 하기 때문에 단순히 해당 인덱스 값에 접근하는 const 함수와 값의 수정인 쓰기 연산이 가능한 비 const 함수 모두를 제공해야 합니다.
int operator[] (int idx) const
{
return arr[idx];
}
int& operator[] (int idx)
{
return arr[idx];
}
- 메모리 접근, 클래스 멤버 접근 연산자 오버로딩(*, -> 연산자)
*, -> 연산자는 스마트 포인터나 반복자 등의 특수한 객체에 사용됩니다.
반복자가 STL의 핵심 구성 요소이므로 *, -> 연산자 오버로딩이 아주 중요합니다.
스마트 포인터
스마트 포인터란 일반 포인터의 기능에 몇 가지 유용한 기능을 추가한 포인터처럼 동작하는 객체입니다.
기존의 일반 포인터를 사용할 시 new 연산 후 delete 연산을 호출하지 않으면 메모리 누수라는 심각한 문제가 발생합니다.
또한, 사용 중에 함수가 종료하거나 예외 등이 발생하면 동적으로 할당한 메모리를 해제하지 못하는 문제가 발생합니다.
수동으로 할당된 메모리에 대한 delete 연산을 해주면 되겠지만, 대신에 스마트 포인터를 사용하면 훨씬 간편합니다.
아래는 스마트 포인터 Pointptr 클래스의 예시입니다.
class Point {
public:
int x, y;
Point(int x, int y): x(x), y(y) {}
};
class PointPtr {
Point* ptr;
public:
PointPtr(Point* p): ptr(p) {}
~PointPtr() {
delete ptr;
}
}
소멸자 내부에 미리 할당 메모리를 자동 삭제하도록 delete를 작성해놓았기 때문에 객체 소멸시 할당 메모리가 자동 삭제됩니다.
이를 통해 프로그램에 예외가 발생하거나 메모리 미해제로 인한 메모리 누수가 발생하는 문제를 예방할 수 있습니다.
이렇게 만들어진 Pointptr 클래스를 실제 포인터처럼 동작하도록 만들기 위해서는 해당 클래스로 생성된 객체(ex. pt1, pt2)로 클래스 내부에 정의된 멤버 함수(=서비스)를 사용할 수 있어야 합니다.
멤버 함수에 접근하기 위해서는 -> 연산자를 추가로 오버로딩해야 합니다.
class Point {
public:
int x, y;
Point(int x, int y): x(x), y(y) {}
};
class PointPtr {
Point* ptr;
public:
PointPtr(Point* p): ptr(p) {}
~PointPtr() {
delete ptr;
}
Point* operator->() const {
return ptr;
}
};
이제 p1->Print()와 같이 p1.operator->() 함수를 호출해서 p1 내부에 보관된 실제 포인터를 반환받고 이 포인터를 이용해 실제 Point의 멤버 함수(p1.operator->()->Print())를 호출할 수 있습니다.
이번에는 일반 포인터의 * 연산자를 이 스마트 포인터 클래스에 추가로 오버로딩해봅시다.
* 연산자는 포인터가 가리키는 객체 자체에 접근할 수 있는 연산자입니다.
class Point {
public:
int x, y;
Point(int x, int y): x(x), y(y) {}
};
class PointPtr {
Point* ptr;
public:
PointPtr(Point* p): ptr(p) {}
~PointPtr() {
delete ptr;
}
Point* operator->() const {
return ptr;
}
Point& operator*() const {
return *ptr; // 레퍼런스로 반환해야 실제 객체에 접근 가능!
}
};
- 이때 * 연산자에 대해서 반환타입에 레퍼런스를 사용하는 이유는?
- 실제 객체에 접근해서 수정할 수 있게 하기 위해
- 레퍼런스를 반환하면 복사 없이 원본 객체를 바로 사용할 수 있어서 불필요한 복사를 방지할 수 있음
- 표준 스마트 포인터도 같은 방식 사용, C++의 스마트 포인터가 원시 포인터와 똑같이 사용할 수 있도록 의도된 디자인 패턴
7. 타입 변환 연산자 오버로딩
사용자가 직접 정의해서 사용할 수 있는 타입 변환은 두 가지가 있습니다.
1) 생성자를 이용한 타입 변환
2) 타입 변환 연산자 오버로딩을 이용한 타입 변환
먼저 생성자를 이용한 타입 변환에 대해서 알아보겠습니다.
- 특정 타입을 인자로 받는 생성자가 있다면 생성자 호출을 통한 타입 변환(객체 생성 후 대입)이 가능합니다.
이렇게 생성자를 이용해 다른 타입을 자신의 타입으로 변환할 수 있습니다.
#include <iostream>
using namespace std;
class A
{
};
class B
{
public:
B() { cout << "B() 생성자" << endl; }
B(A& _a) { cout << "B(A _a) 생성자" << endl; }
B(int n) { cout << "B(dobule d) 생성자" << endl; }
};
int main()
{
A a;
int n = 10;
double d = 5.5;
B b; // B() 생성자 호출
b = a; // b = B(a) 암시적 생성자 호출 후 대입
b = n; // b = B(n) 암시적 생성자 호출 후 대입
b = d; // b = B(d) 암시적 생성자 호출 후 대입
return 0;
}
코드 b = a; 에서 컴파일러는 A 타입의 객체를 B 타입으로 변환하기 위해서 생성자를 확인합니다.
A 타입의 객체(a)를 인자로 받는 생성자가 있으므로 이 생성자를 호출해 B 타입의 객체를 생성합니다.
만약 생성자를 이용한 형변환을 의도하지 않았더라도, 해당 자료형을 인자로 받는 생성자가 있다면
컴파일러는 해당 자료형을 사용한 생성자를 호출해 객체를 생성합니다.
따라서 생성자는 명시적 호출만 가능하도록 explicit 키워드를 지정합니다.
explicit Point (int _x = 0, int _y = 0) : x (_x), y(_y) { }
explicit 생성자는 명시적 호출만 가능하므로 Point(10)과 같이 호출합니다.
이렇듯 암시적인 생성자 형변환을 의도하지 않는 한 인자를 갖는 모든 생성자는 모두 explicit 생성자로 만들어야 합니다.
- 타입 변환 연산자 오버로딩을 이용한 타입 변환
타입 변환 연산자 오버로딩을 사용하면 자신의 타입을 다른 타입으로 변환할 수 있습니다.
함수 이름 | operator <type>() |
반환형? | 명시하지 않음. 함수 이름 자체가 반환형 |
사용 목적 | 객체를 다른 타입으로 변환할 수 있도록 함 |
유사한 예 | 생성자도 반환형을 명시하지 않음 |
예시 | operator int(), operator std::string() 등 |
아래는 타입 변환 연산자 오버로딩의 예제입니다.
#include <iostream>
using namespace std;
class A
{
};
class B
{
public:
operator A()
{
cout << "operator A() 호출" << endl;
return A();
}
operator int()
{
cout << "operator int() 호출" << endl;
return 10;
}
operator double()
{
cout << "operator double() 호출" << endl;
return 5.5;
}
};
int main()
{
A a;
int n;
double d;
B b;
a = b; // b.operator A()의 암시적 호출
n = b; // b.operator int()의 암시적 호출
d = b; // b.operator double()의 암시적 호출
cout << endl;
a = b.operator A(); // 명시적 호출
n = b.operator int(); // 명시적 호출
d = b.operator double(); // 명시적 호출
return 0;
}
여기서 주의할 점은, 타입 변환 연산자는 생성자나 소멸자처럼 반환 타입을 지정하지 않는 다는 것입니다.
또한 타입 변환 연산자에 대해서 const 객체나 비 const 객체 모두 타입 변환이 가능하게끔 const 멤버 함수로 정의할 수 있습니다.
operator int() const
{
return x;
}
'C,C++ > [서적] 뇌를 자극하는 C++ STL' 카테고리의 다른 글
Chapter06-01 시퀀스 컨테이너 : vector (0) | 2023.02.20 |
---|---|
Chapter05. STL 소개 (0) | 2023.02.16 |
Chapter04. 템플릿 (0) | 2023.02.15 |
Chapter03. 함수 객체 (0) | 2023.02.15 |
Chapter02. 함수 포인터 (0) | 2023.02.14 |