C/C++ 관련 헷갈리기 쉬운 것 정리8(저장용,메모용, 윤성우 열혈 C++ 프로그래밍 정리 CH 11)

RisingJade의 개발기록·2022년 2월 25일
0

Chapter 11. 연산자 오버로딩2


11-1. 반드시 해야 하는 대입 연산자의 오버로딩

객체간 대입연산의 비밀: 디폴트 대입 연산자

잠시 복사 생성자 복습

  • 따로 정의하지 않으면 디폴트 복사 생성자가 삽입된다.
  • 디폭트 복사 생성자는 멤버 대 멤버의 복사(얕은 복사)를 진행한다.

    깊은 복사/ 얕은 복사를 다시 한번 정리하자면.
    -> 일단 얕은 복사는 말그대로 멤버대 멤버를 고대로 복사한다. 이때, 복사한다는 건 말 그대로 같은 메모리의 주소를 가리키도록 복사한다는 것이다. 따라서 복사한 本데이터를 수정하면 복사받은 데이터도 변경되는 기상천외한 일이 생긴다. 각종 메모리 누수나 런타임에러를 일으키는 주범이 된다.
    -> 깊은 복사는 복사 시 해당 객체와 인스턴스 변수까지 전부 복사해서 새로운 주소에 담는 방식이다.
    새 주소에 담기 때문에 本데이터가 바뀌어도 변경되지 않는다.

  • 생성자 내에서 동적 할당을 한다면, 그리고 싶은 복사가 필요하면 직접 정의해야 한다.

디폴트 대입 연산자

  • 따로 정의하지 않으면 디폴트 대입 생성자가 삽입된다.
  • 디폴트 대입 생성자는 멤버 대 멤버의 복사(얕은 복사, 주소 복사)를 진행한다.
  • 생성자 내에서 동적 할당을 한다면, 그리고 싶은 복사가 필요하면 직접 정의해야 한다.

디폴트 대입 연산자의 문제점

class SimpleClass {
private:
	int num1;
	char * name;
public:
	SimpleClass(const char *myname, int n2) {
		int len = strlen(myname) + 1;
		name = new char(len);
		strcpy(name, myname);
		num1 = n2;
		
	}
	void SimpleFunc() {
		cout << "simple func" << endl;
	}

	void SimpleFunc() const {
		cout << "const simple func" << endl;
	}
	int GetNum1() const {
		return num1;
	}
	void showData() const {
		cout << name << ' ' << num1 << endl;
	}
	SimpleClass& adder(int n) {
		num1++;
		return *this;
	}
	~SimpleClass() {
		cout << "delete simpleclass" << endl;
		delete []name;		
	}
};

위와 같은 클래스가 있을때

int main(void){
	SimpleClass man1("myname", 29);
	SimpleClass man2("asd", 10);
	man2 = man1;
}
  • 위와 같이 메인함수에서 man2 =man1으로 대입해버리면 man2man1에 디폴트 대입연산자가 작동하여 얕은복사가 되어 완전히 같은 메모리를 가리키고 공유하게 된다. 이렇게 되면 현재 생성되어있는 "asd"라는 문자열은 낙동강 오리알이 되어 메모리 누수가 되고, man2, man1중 하나만 사라져도 둘 다 못쓰게 되버린다.
  • 따라서 깊은 복사를 진행하도록 하며, 메모리 누수가 발생하지 않도록, 깊은 복사에 앞서 메모리 해제의 과정을 거치게 대입 연산자를 오버로딩 해주면 된다.
Person& operator=(const Person& ref){
	delete []name; //메모리 누수를 막기 위한 메모리 해제
    int len = strlen(ref.name)+1;
    name = new char[len];
    strcpy(name, ref.name);
    age=ref.age;
    return *this;
}

상속 구조에서의 대입 연산자 호출

  • 대입연산자는 생성자가 아니다!
    -> 유도 클래스의 생성자에서 아무런 명시를 하지 않아도 기초 클래스의 생성자가 호출되지만,
    유도클래스의 대입 연산자에는 아무런 명시를 하지 않으면, 기초 클래스의 대입 연산자가 호출되지 않는다.!

    유도 클래스의 대입 연산자 정의에서, 명시적으로 기초 클래스의 대입 연산자 호출문을 삽입하지 않으면, 기초 클래스의 대입 연산자는 호출되지 않아서, 기초 클래스의 멤버변수는 멤버 대 멤버의 복사 대상에서 제외된다!

Second& operator=(const Second& ref){
	First::operator=(ref);
    num3=ref.num3;
    num4=ref.num4;
    return this*;
}

이니셜라이저가 성능 향상에 도움을 주는 이유

BBB(const AAA& ref) : mem(ref); // 이건 AAA mem = ref로 번역되어 선언과 동시에 초기화가 이루어짐
CCC(const AAA& ref) : {mem=ref;); // AAA& ref 라는 선언과 mem = ref라는 선언이 각각 한번씩 진행됨
  • 위의 코드와 주석을 통해 이니셜라이저를 이용해 초기화를 진행하면, 함수의 호출횟수를 줄일 수 있고 초기화의 과정을 단순화 시킬 수 있어서 약간의 성능향상을 기대할 수 있다.

11-1. 배열의 인덱스 연산자 오버로딩

배열보다 나은 배열 클래스

  • C, C++의 기본 배열은 "경계 검사"를 하지 않는다는 단점이 있다. 이러한 단점을 해결하기 위해 배열 클래스를 사용해보자

class BoundCheckIntArray {
private:
	int * arr;
	int arrlen;
public:
	BoundCheckIntArray(int len) : arrlen(len) {
		arr = new int[len];
	}

	int& operator[] (int idx) {
		if (idx < 0 || idx >= arrlen) {
			cout << "Arrau index out of bound exception" >> endl;
			exit(1);
		}
		return arr[idx];
	}
	~BoundCheckIntArray() {
		delete[] arr;
	}
};

int main(void)
{
	BoundCheckIntArray arr(5);
	for (int i = 0; i < 5; i++) {
		arr[i] = (i + 1) * 11;
	}
	for (int i = 0; i < 6; i++) {
		cout << arr[i] << endl;
	}
    return 0;
}

위에서 보는 바와 같이 arr[5]에 접근하게 되면 에러 메시지를 출력하고 exit(1)으로 에러를 뱉어낸다.

TIP

배열은 저장소의 일종이고, 저장소에 저장된 데이터는 '유일성'이 보장되어야 하기 때문에, 대부분의 경우 저장소의 복사는 불필요하거나 잘못된 일로 간주된다. 따라서 깊은 복사가 진행되도록 클래스를 정의할 것이 아니라, 위의 코드에서 보이듯이 빈 상태로 정의된 복사 생성자와 대입 연산자를 private 멤버로 둠으로써 복사와 대입을 원천적으로 막는 것이 좋은 선택이 되기도 한다.

const를 이용한 오버로딩의 활용

  • 방금 본 BoundCheckIntArray을 외부 함수가 const 참조형태의 매개변수로 부르는 것을 생각해보자
void ShowAllData(const BoundCheckIntArray& ref)
{
	int len = ref.GetArrLen();
    for(int idx=0; i<5; i++{
    	cout << ref[idx] << endl;//컴파일 에러 발생!!
    }
}
  • 왜 컴파일 에러가 발생할까?
    일단 매개변수 형이 const인것은 매우 합당하다. ShowAllData()함수는 어디까지나 데이터를 보여주는 함수이므로 내부에서 데이터의 값이 변경되면 안되고 변경되지 않도록 const를 선언하는것은 당연하다. 하지만, 이때 const때문에 ref[idx]가 컴파일 에러가 난다.
    왜냐하면 우리가 오버로딩한 operator[]함수는 const함수가 아니기 때문이다...
    따라서, 이를 해결하기 위해선

    const의 선언유무도 함수 오버로딩의 조건에 해당된다.

    이 조건을 이용하면 된다!

    int& operator[] (int idx) {//원래 것
    		if (idx < 0 || idx >= arrlen) {
    			cout << "Arrau index out of bound exception" >> endl;
    			exit(1);
    		}
    		return arr[idx];
    	}
    int operator[] const (int idx) {// 반환형이 int다! 새로운 객체를 생성해서 넘긴다. 어처피 이걸 쓸 함수는 값만 필요하지 참조값은 필요없으니 참조를 통해
    //괜히 문제생길일을 원천차단한다.
    		if (idx < 0 || idx >= arrlen) {
    			cout << "Arrau index out of bound exception" >> endl;
    			exit(1);
    		}
    		return arr[idx];
    	}
  • 위와 같이 const함수 오버로딩을 사용하여 문제를 해결할수있다! 대박!!


    11-1. 그 이외의 연산자 오버로딩

    new 연산자 오버로딩

  • new 연산자가 하는 일

    • 메모리 공간의 할당
    • 생성자의 호출
    • 할당하고자하는 자료형에 맞게 반환된 주소 값의 형 변환
  • 이 중 new 연산자 오버로딩을 통해 할 수 있는건 첫번째에 해당하는 메모리 공간 할당만 오버로딩 할 수 있다.

  • new 오버로딩은 아래와 같이 하도록 이미 약속 되어있다.
    void * operator new (size_t size) { ... } (이때 사이즈는 바이트 단위이다, 즉 char와 같은 크기다.
    반환형은 반드시 void포인터 형이고 매개변수형은 size_t이어야 한다.
    이때, Point * ptr = new Point(3,4)를 작성하면 먼저 필요한 메모리 공간을 계산하고 그 후 operator new를 호출하여 계산된 크기의 값을 인자로 전달한다!

  • new Point(3,4)에서 보이듯 아직 객체 생성이 다 되지 않았는데 new를 쓸 수 있다.
    그 이유는 **기본적으로 operator newoperator delete는 둘다 static으로 선언된 함수이기 때문이다!!

    delete 연산자 오버로딩

    new 연산자 오버로딩과 큰 맥락은 같다.
    void operator delete (void * adr) { ... }

    Operator new & operator new []

    new 연산자는 2가지 아래의 방식으로 오버로딩 가능하다

    void * operator new (size_t size) { ... }
    void * operator new[] (size_t size) { ... }

    delete 연산자는 2가지 아래의 방식으로 오버로딩 가능하다

    void operator delete (void * adr) { ... }
    void operator delete[] (void * adr) { ... } 

    포인터 연산자 오버로딩

  • ->: 포인터가 가리키는 객체의 멤버에 접근

  • * : 포인터가 가리키는 객체에 접근

    class Number{
    private:
    	int num;
    public:
    ... 
    Number * operator->(){
    	return this;
    }
    Number& operator*(){
    	return &this;
    }
    }
    int main(void){
    	...
       Number num(20);
       num.ShowData();
       (*num)=30;
       num->ShowData();
       (*num).ShowData();
       return 0;
    }

    나머지는 잘 이해가 가지만 num->ShowData()는 무언가 문제가 있어보인다. 저 부분을 일반적인 해석을 하면
    num.operator() ShowData();인데 여기서 operator->()가 반환하는 것은 주소값이니 (주소값) ShowData()
    이라는 호출은 논리적으로 말이 되지않는다. 따라서 이때 반환되는 주소 값을 대상으로 적절한 연산이 가능하도록 -?연산자를
    하나 더 추가하며, num.operator->() -> ShowData()형태를 만들고 해석을 진행한다.

    스마트 포인터

    스마트 포인터는 자기 스스로 하는 일이 존재하는 포인터로, 사실 포인터의 역할을 하는 객체이다. 따라서 구해야 할 대상이 아닌, 구현해야 할 대상임을 잊지 말자!.(여기선 라이브러리에서 제동하는 스마트 포인터의 사용방법이 아닌 직접 구현해보는 스마트 포인터를 다룬다)

class SmartPtr{
private:
	Point * posptr;
public:
	smartPtr(Point * ptr) : posptr(ptr){}
    Point& operator*() const
    {
    	retirm *posptr;
    }
    Point* operator->() const
    {
    	retirm posptr;
    }
    ~SmartPtr()
    {
    	delete posptr;
    }
}

위에서 스마트 포인터의 핵심은 *, ->의 오버로딩에 있다.
Point 형을 받고 반환하며 자연스럽게 Point클래스를 다룰 수 있다. 여기서 스마트포인터는 소멸자로 Point객체의 소멸까지 담당해서 편한 Point클래스의 사용이 가능하다

()연산자의 오버로딩과 펑터(Functor)

  • 클래스를 하나 정의하고 그 클래스의 객체가 객체임에도 불구하고 함수처럼 동작하면 그 클래스를 펑터(Functor)라고 한다.
  • 펑터를 쓰는 이유
    예시를 하나 보자
class SortRule{
public:
	virtual bool operator()(int num1, int num2) const = 0; // 순수 가상함수,즉 유도클래스에서 행동을 정함
}
class AscendingSort : public SortRule
{
	public:
    	bool operator()(int num1, int num2) const{
        	if(num1> num2)
            	return true;
             else
             	return false;
        }
}
class descendingSort : public SortRule
{
	public:
    	bool operator()(int num1, int num2) const{
        	if(num1 <num2)
            	return true;
             else
             	return false;
        }
}
class DataStorage
{
private:
	int * arr;
    int idx;
    const int MAX_LEN;
public
...//생성자랑 각종 함수들
void SortData(const SortRule& functor)//버블 소트
{
	for(int i =0; i < (idx-1); i++){
    	for(int j =0; j < (idx-1) - i; j++)
        {
        	if(functor(arr[j], arr[j+1])
            {
            	int temp=arr[j];
                arr[j] = arr[j+1];
                arr[j+1] = temp;
            }
        }
    }
}
}

int main(void){
	DataStorage storage(5);
    storage.AddData(40);
    ...
    storage.SortData(AscendingSort());//AscendingSort는 객체인데 흡사 함수처럼 쓰인다 -> 펑터
    
    storage.SortData(DescendingSort());//DescendingSort는 객체인데 흡사 함수처럼 쓰인다 -> 펑터
}

여기서 보면

void SortData(const SortRule& functor)//버블 소트
{
	for(int i =0; i < (idx-1); i++){
    	for(int j =0; j < (idx-1) - i; j++)
        {
        	if(functor(arr[j], arr[j+1])
            {
            	int temp=arr[j];
                arr[j] = arr[j+1];
                arr[j+1] = temp;
            }
        }
    }
}

매개변수 형이 SortRule의 참조형이므로, SortRule 클래스를 상속하는 AscendingSort 클래스와 DescendingSort 클래스의 객체는 인자로 전달 가능하고, 기초 클래스인 SortRuleoperator()는 순수 가상함수로 선언되었으니, 유도 클래스의 operator()가 대신 호출된다. 따라서, 펑터로 무엇이 전달되느냐에 따라 정렬의 기준이 바뀌게 된다.
그리고 이것이 펑터를 정의하는 이유다

임시객체로의 자동 형 변환 연산자(Conversion Operator)

A형 객체가 와야 할 위치에 B형 데이터(또는 객체)가 왔을 경우, B형 데이터를 인자로 전달받는 A형 클래스의 생성자 호출을 통해서 A형 임시객체를 생성한다.
이렇듯 기본 자료형 데이터를 객체로 형 변환하는 것은 적절한 생성자의 정의를 통해서 얼마든지 가능하다. 또한 반대로 객체를 기본 자료형 데이터로 형변환 하는 것도 가능하다.

실제 형변환을 위한 operator 형태는 다음과 같다.

class Number{
private:
	int num;
public:
...
Number& operator=(const Number& ref)
{
	cout << "operator=()" << endl;
    num = ref.num;
    return *this;
}
operator int () // 형 변환 연산자의 오버로딩
{
	return num;
}
}

여기서 형 변환 연산자는

operator int () // 형 변환 연산자의 오버로딩
{
	return num;
}
}

이것으로 이 함수를 통해 형 변환 연산자는 반환형을 명시하지 않는다는 것을 알 수 있다. 하지만 return 문에 의한 값의 반환은 얼마든지 가능하다. 여기서 int의 뜻은

int형으로 형 변환해야 하는 상황에서 호출되는 함수이다.

이를 통해, Number num2 = num1 + 20과 같은 계산이 가능해지며, 이때, num1객체의 operator int 함수과 호출되어, 이때 반환하는 값 30과 20의 덧셈연산이 진행되고 연산의 결과로 num2객체가 생성 된 것이다.

profile
언제나 감사하며 살자!

0개의 댓글