ㅇ 공부#언어/(C++) 자료구조 및 STL

(C++) 초급 2장. 연산자 오버로딩(연산자 중복) 복습하기(1/2)

BrainKimDu 2023. 8. 4. 19:54

주의 : 이 글은 C++의 기초문법에 대해 상세하게 다루지 않습니다.

※ 뇌를 자극하는 C++ STL 의 책을 참고하여 개인적으로 정리한 글입니다. 
이 글의 목적은 해당 책의 내용을 인용하여 더 쉽게 이해하고자 정리하고, 더 쉬운 예제를 통해 이해하는 것을 목표로 하고 있습니다. 깊게 공부하고 싶다면 책을 구매하는 것을 추천드립니다.
책의 저작권 등등 각종 권한은 출판사와 지은이/옮긴이에 있습니다.

- 출판사: (한빛 미디어)
- 지음: 공동환

※ 이 블로그는 포토폴리오 목적으로 별도의 수익을 창출하지 않습니다.

뇌를 자극하는 C++ STL - YES24

 

뇌를 자극하는 C++ STL - YES24

코드 중심으로 설명했다. 코드를 실습하면서 한 단계씩 실력을 쌓을 수 있게 했다. 단계별로 난이도를 조금씩 올리고 예제를 점진적으로 개선하는 방법을 택해 독자가 STL의 동작 원리와 구현 원

www.yes24.com


함수 호출 연산자 오버로딩 () 연산자

print(5) 는 다음의 3가지로 해석이 가능합니다.
print 함수의 이름
print 함수의 포인터
print 함수의 객체

여기서는 함수의 객체를 보입니다. 위 3가지는 어떻게 다를까요?

먼저 함수의 이름일 경우 입니다.

#include<iostream>
void print1(int arg)
{
	std::cout << arg;
}

int main() {
	print1(5);
}

print 라는 함수를 호출했습니다.

 

두 번째는 함수의 포인터입니다.

#include<iostream>
void print1(int arg)
{
	std::cout << arg;
}

int main() {
	void (*print2)(int) = print1;
	print2(5);
}

뭔가 이상합니다. 이 활용은 도대체 무엇일까.. 이 코드는 print2라는 함수 포인터 변수를 선언습니다. 즉 print1의 시작 주소로 초기화 된 상태라고 합니다. 함수 포인터는 콜백 함수, 동적으로 함수 선택, 함수 테이블, 객체 지향 프로그래밍, 동적 라이브러리 사용에 사용된다고 합니다.

 

세 번째는 함수 객체의 경우입니다.

#include<iostream>

class Function 
{
public:
	void operator() (int arg) const
	{
		std::cout << arg;
	}
};

int main() 
{
	Function print3;
	print3(10);
}

다음처럼 함수 객체로 선언되고, 연산자 중복에 의해 ()가 정의된 상황입니다. 우리는 연산자 중복을 복습하고 있음으로 함수 객체를 더 들어가 보도록 합시다.

이런 활용도 가능합니다.

#include<iostream>

class Function 
{
public:
	void operator() (int arg) const
	{
		std::cout << arg << std::endl;
	}
	void operator() (int arg1, int arg2) const
	{
		std::cout << arg1 << " " << arg2 << std::endl;
	}
	void operator() (int arg1, int arg2, int arg3) const
	{
		std::cout << arg1 << " " << arg2 << " " << arg3 << std::endl;
	}
};

int main() 
{
	Function print3;
	print3(10);
	print3(10, 20);
	print3(10, 20, 30);
}

 

배열 인덱스 연산자 오버로딩 [] 연산자

앞의 ()와 비슷합니다.

#include<iostream>

class Function 
{
public:
	void operator[] (int arg) const
	{
		std::cout << arg << std::endl;
	}
};

int main() 
{
	Function print3;
	print3[10];
}

[] 연산자는 일반적으로 컨테이너 객체에 사용됩니다. 뭐 vector 같은 느낌이죠. 그래서 이런식으로 만들 수 있습니다.

#include<iostream>

class Vector 
{
	int *arr;
	int size;
	int capacity;

public:
	Vector(int cap = 100) : arr(0), size(0), capacity(cap)
	{
		arr = new int[capacity];
	}

	void push_back(int a)
	{
		if (size < capacity)
			arr[size++] = a;
	}

	int operator[] (int arg) const
	{
		return arr[arg];
	}
};

int main() 
{
	Vector v;
	v.push_back(10);
	v.push_back(20);
	std::cout << v[1];
}

 

(사실 vector보다는 배열에 가깝습니다.)코드를 확인해보시면, 크기 100짜리의 동적 배열을 할당받습니다. 그리고 push_back을 하면 맨 뒤에 삽입이 됩니다.

그래서 결과로는 제일 마지막에 넣은 20을 출력받게 됩니다.

만약 v[1] 의 값을 변경하고 싶다면?

현재는 const가 붙어있기 때문에 arr[arg]를 수정할 수 없습니다.

이를 해결하기 위해서는 다음의 연산자 중복 함수를 선언할 수 있습니다.

int& operator[] (int arg) 
	{
		return arr[arg];
	}

2로 변경이 가능한 모습을 보여줍니다.

 

메모리 접근, 클래스 멤버 접근 연산자 오버로딩(*, ->연산자)

이 연산자는 스마트 포인터나 반복자(iterator)등의 특수한 객체에 사용됩니다. 스마트 포인터란 일반 포인터의 기능에 몇 가지 유용한 기능을 추가한 포인터처럼 동작하는 객체입니다.

스마트 포인터를 사용하면 동적 객체를 자동으로 제거하는 기능이있습니다. 그러면 포인터와 스마트 포인터의 차이를 살펴보겠습니다.

#include<iostream>

class Point 
{
	int x;
	int y;
public:
	Point(int x = 0, int y = 0) {
		this->x = x;
		this->y = y;
	}
	
	void print() const
	{
		std::cout << x << " " << y << std::endl;
	}
};

int main() 
{
	Point *p = new Point(1, 2);  
	p->print();   // p.operator->()->print()
	delete p;
}

다음의 코드는 우리가 평소에 사용하는 일반적인 포인터 입니다. 코드가 끝날 때 detele를 해줌으로 동적으로 할당한 메모리를 해체해주어야합니다.

참고로 생성자는 다음의 코드로 간소화가 가능합니다.

Point(int x = 0, int y = 0) 
{
		this->x = x;
		this->y = y;
}

다음의 코드는 다음처럼 간소화가 가능합니다.

Point(int _x = 0, int _y = 0):x(_x), y(_y) { }

그러면 이제 스마트 포인터에 대해 알아봅시다.

#include<iostream>

class Point {
	int x;
	int y;
public:
	Point(int _x = 0, int _y = 0):x(_x), y(_y) { }
	
	void print() const
	{
		std::cout << x << " " << y << std::endl;
	}
};

class PointPtr
{
	Point* ptr;
public:
	PointPtr(Point* p) :ptr(p) { }

	~PointPtr()
	{
		delete ptr;
	}
};

int main() 
{
	PointPtr p =new Point(1, 2);
	//p->print();
}

다음처럼 포인터를 생성하는 클래스를 하나 추가적으로 선언합니다. (PointPtr) 이렇게 해놓는 경우 객체가 생성되고 프로그램이 종료될 때 객체의 소멸자가 실행되면서 자동으로 동적 객체의 반환작업이 진행됩니다.

그러나 일반 pointer 처럼 p->print()를 사용할 수 없습니다. 어떻게 사용하는지 한 번 알아봅시다.

이 때 등장하는 것이 -> 연산자 중복입니다(오버로딩)

Point* operator->() const
	{
		return ptr;
	}

Point * 를 반환하는 오버로딩 함수입니다. 

어떤 일이 일어날지 확인해봅시다. 우선 

p->print();

다음의 명령어가 실행되면 oprator->가 실행됩니다. 이 실행에 포인터가 반환되고 이를 통해 멤버 함수 'print()'가 호출됩니다.

그리고 일반 포인터의 * 연산자는 포인터가 가리키는 객체 자체임으로 스마트 포인터에도 * 연산이 가능하도록 연산자를 오버로딩할 수 있습니다.

Point& operator*() const
	{
		return *ptr;
	}

*p 연산이 먼저 되어야 하기 때문에 (*p)로 선언한다고합니다.

 

 

타입 변환 연산자 오버로딩

int main() 
{
	Point b;
	A a;
	b = a;
	b = 1;
	b = 1.1;
}

타입변환에는 두 가지가 존재합니다. 
1. 생성자를 이용해서 타입 변환
2. 타입 변환 연산자 오버로딩을 이용한 타입 변환

생성자를 이용한 타입 변환 부터 확인을 해봅시다.

#include<iostream>
class A
{
};

class Point {
public:
	Point()
	{
		std::cout << "Point() ";
	}
	Point(int n)
	{
		std::cout << "Point(int n) ";
	}
	Point(A& a)
	{
		std::cout << "Point(A& a) ";
	}
	Point(double d)
	{
		std::cout << "Point(double d) ";
	}
};

다음처럼 매개변수에 형식이 다르게 넣을 줄 수 있습니다. 그리고 main문에서는 

int main() 
{
	Point b;
	A a;
	b = a;
	b = 1;
	b = 1.1;
}

다음처럼 적어주면 결과는 다음처럼 나오게 됩니다.

그러면 앞서서 보았던 코드중에서 

#include<iostream>

class Point 
{
	int x;
	int y;
public:
	Point(int x = 0, int y = 0) {
		this->x = x;
		this->y = y;
	}
	
	void print() const
	{
		std::cout << x << " " << y << std::endl;
	}
};

int main() 
{
	Point p;
    p.print();
}

우리는 암시적 생성자 호출에 대해서 잠시 생각해볼 필요가 있습니다. 우리는 p에 값을 대입하지 않았지만 결과는 0, 0이 나오게됩니다.

다음처럼 객체를 선언하면 어떻게 될까요?

오류가 아니라 다음처럼 결과가 나오게 됩니다. 이를 방지하기 위해서는 생성자 앞에 explicit을 붙여주어야합니다.

그리하여 암시적 생성자 호출을 막을 수 있습니다.

 

타입 변환 연산자 오버로딩을 이용한 타입변환

이번에는 연산자 오버로딩을 이용한 타입변환을 알아봅시다. 타입변환 연산자는 반환값을 설정하지 않습니다.

#include<iostream>
class A
{
};

class Point {
public:
	
	operator int()
	{
		std::cout << "Point(int n) ";
		return int();
	}
	operator A()
	{
		std::cout << "Point(A& a) ";
		return A();
	}
	operator double()
	{
		std::cout << "Point(double d) ";
		return double();
	}
};



int main() 
{
	Point b;
	int n;
	double f;
	A a;
	a = b;
	n = b;
	f = b;
}