본문 바로가기
Programming/열혈 C++ 프로그래밍(저자 윤성우)

Ch 11. 연산자 오버로딩 2

by minjunkim.dev 2020. 8. 17.

    모든 내용은 [윤성우 저, "열혈 C++ 프로그래밍", 오렌지미디어] 를 기반으로 제 나름대로 이해하여 정리한 것입니다. 다소 부정확한 내용이 있을수도 있으니 이를 유념하고 봐주세요!


# 대입연산자 오버로딩 - 객체간 대입연산의 비밀

- 대입연산자는 그 성격이 복사생성자와 매우 유사하다.

# 복사생성자
1) 정의하지 않으면 디폴트 복사생성자가 삽입됨
2) 디폴트 복사 생성자는 멤버대멤버 복사(얕은 복사)를 진행
3) 생성자 내에서 동적할당을 한다면, 그리고 깊은 복사가 필요하다면 직접 정의해야함

# 대입연산자
1) 정의하지 않으면 디폴트 대입생성자가 삽입됨
2) 디폴트 대입 생성자는 멤버대멤버 복사(얕은 복사)를 진행
3) 생성자 내에서 동적할당을 한다면, 그리고 깊은 복사가 필요하다면 직접 정의해야함

# 복사생성자 vs 대입 연산자 : 유일한 차이점!
- 복사생성자는 새로운 객체를 "생성하자마자 초기화"를 하고자 할 때 호출
- 이에 반해 대입연산자는 "이미 생성된 객체에 대입"하고자 할 때 호출

# 생성자 내에서 동적할당하는 경우 대입연산자는,
1. 깊은 복사를 진행하도록 정의해야 하고,
2. 메모리 누수가 발생하지 않도록, 깊은 복사에 앞서 필요한 경우

"기존의 할당된 메모리를 해제하는" 과정을 거친다.

# 대입 연산자는 생성자가 아니므로,
유도 클래스의 생성자에는 아무런 명시를 하지 않아도 기초 클래스의 생성자가 호출되지만
대입 연산자는 반드시 명시적으로 호출해주어야만 한다.
(단, 디폴트 대입 연산자는 기초 클래스의 대입 연산자를 자동으로 호출함)

# 멤버 이니셜라이저의 성능 향상
1. 멤버 이니셜라이저 초기화시 : 선언과 동시에 초기화가 이뤄지는 형태로 바이너리 코드 생성
2. 생성자 몸체부분에서 대입연산을 통한 초기화시 :
선언과 초기화를 각각 별도의 문장에서 진행하는 형태로 바이너리 코드 생성

# 배열 인덱스 [] 연산자 오버로딩(멤버함수 형태로만 가능)
- const 선언 유무도 함수 오버로딩 조건에 해당한다.
- 객체보다는 객체의 포인터를 저장하는 것이 더 많이 쓰인다.
- 복사, 대입의 원천 차단을 위해서는 복사생성자, 대입연산자 오버로딩함수를 private에 빈몸체로 정의하면 됨

# new 연산자가 하는 일
1) 메모리 공간 할당 
2) 생성자 호출
3) 할당하고자 하는 자료형에 맞게, 반환된 주소값의 형변환
=> new 연산자 오버로딩시 우리는 1)에 대한 작업만 하면 됨. 나머지는 컴파일러가 알아서 해줌.
=> new 연산자 오버로딩 형태

void* operator new(size_t size) // 크기정보(size)는 바이트 단위
{
    void* adr=new char[size];
    return adr;
}

void* operator new[](size_t size) // 크기정보(size)는 바이트 단위
{
    void* adr=new char[size];
    return adr;
}

 

- 컴파일러가 size를 계산해서 전달해준다.
- size 가지고 우리는 new로 메모리 공간을 할당하면 된다.

- 다른 작업이 필요하면 추가적으로 진행하면 된다.
- typedef unsigned int size_t;

# delete 연산자 오버로딩
- delete ptr; // 객체의 소멸을 명령하면
1) 컴파일러가 ptr이 가리키는 객체의 소멸자 호출
2) void operator delete(void *adr) 형태로 정의된

오버로딩 멤버함수의 파라미터 adr에 ptr에 저장된 주소값 전달
=> 즉, delete 오버로딩 멤버함수에서는 메모리 공간의 소멸을 책임져야한다.
=> 사용하는 컴파일러에서 void 포인터형 대상의 delete 연산을 허용하지 않는다면,
delete[](reinterpret_cast<char*>adr); 으로 연산 진행하면 된다.

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

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


# operator new와 operator delete 연산자 오버로딩 멤버함수는
(static 선언을 하지 않아도) 사실 static 멤버함수이다.

그래서 객체 생성 유무와는 별도로 클래스 별로 공간에 이미 할당되기 때문에
객체 생성 과정에서 호출이 가능한 것이다.

# 포인터 연산자 오버로딩(단항연산자)
-> : 포인터가 가리키는 객체의 멤버에 접근
* : 포인터가 가리키는 객체에 접근

# 스마트 포인터(포인터 역할을 하는 객체를 의미)
- 스마트 포인터의 기본은 ->, * 연산자 오버로딩이다.

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


# 함수호출 () 연산자의 오버로딩과 펑터(functor)
- 함수호출 ()연산자 오버로딩을 통해 객체를 함수처럼 사용할 수 있다.
- 함수처럼 동작하는 클래스 == Functor(함수 오브젝트)

- 펑터는 함수 또는 객체의 동장박심에 유연함을 제공할 때 주로 사용됨

class Adder
{
public:
    int operator()(const int& n1, const int &n2)
    {
        return n1+n2;
    }
    
    double operator()(const double& e1, const double &e2)
    {
        return e1+e2;
    }
    
    Point operator()(const Point& n1, const Point &n2)
    {
        return pos1+pos2; // Point 클래스의 operator+ 오버로딩을 가정
    }
};


# 형 변환 연산자
- 두 객체의 자료형이 일치하는 경우의 대입연산은 대입 연산자 오버로딩에 의해 이뤄짐.
- 그러나, 자료형이 일치하지 않는 경우엔

1) 임시객체를 생성(A 자료형을 통해 B 객체를 생성) 후 대입하거나,

2) 형변환 연산자 오버로딩 필요(B 객체에서 A 자료형을 반환받음)

class Number
{
private:
    int num;
public:
    ....
    operator int () // 형 변환 연산자 오버로딩
    {
        return num
    }
};

# 매개변수의 디폴트값은 함수 원형 선언시 설정한다.

# 적절한 클래스의 등장은 다른 클래스의 정의를 간결하게 해준다.

# [] 연산자 오버로딩시, const 선언 역시 함수오버로딩 조건에 포함됨을 기억하자.

# const 멤버함수는 멤버함수 내에서 객체의 멤버변수를 변경하지 않는다는 것을 보장하는 함수

 

# const 객체는 const 멤버함수만 호출이 가능하다.

# 증감연산자 오버로딩

operator++() => 전위연산자
operator++(int) => 후위연산자(dummy 정수형 인자 0을 전달함)

# friend 선언은 캡슐화(정보은닉)를 저해하므로, Get(), Set() 함수를 통해 멤버변수에 접근해보자.

# 임시객체 생성법
- 클래스명(인자) => 그 문장에서만 생성되고 참조자로 참조하지 않는 이상 그 문장을 벗어나면 소멸된다.

# [] 연산자 오버로딩은 const 함수, 비 const 함수(쓰기 연산을 위함임) 모두를 제공해야 한다.
- operator[](...) : 읽기, 쓰기 모두 가능한 비 const 객체에 사용됨
- operator[](...) const : 읽기만 가능한 const 객체에 사용됨

# 스마트 포인터
- *, -> 연산자 오버로딩이 중요하다.
- 소멸자에서 자동으로 동적할당한 메모리 해제 해주므로 편리하다.(메모리 누수가 날 확률이 적음)

# 사용자가 직접 정의해서 사용할 수 있는 타입 변환
1) 생성자를 이용한 타입 변환(허용하고 싶지 않으면 explicit 키워드 사용)
2) 타입 변환 연산자 오버로딩을 이용한 타입 변환( operator 반환형 () )
- 타입 변환 연산자는 반환형이 없음

# 생성자, 복사 생성자, 대입 연산자(오버로딩)

1) 그냥 객체를 선언만 하여 객체를 생성하면 (디폴트) 생성자가 호출됨
2) 객체를 생성할 때 같은 타입을 인자로 넣어준다면 복사 생성자가 호출됨
3) 객체를 생성할 때 같은 타입을 대입 연산한다면 복사 생성자가 호출됨

(단, explicit 키워드 선언을 안했을 시에만 가능)
4) 객체를 생성 이후, 같은 타입을 대입 연산한다면 대입 연산자(오버로딩) 실행됨

# C++에서의 함수
1. 정적함수

1) 전역함수

2) namespace 내의 전역함수

3) static 멤버함수

2. 멤버함수

# C++에서의 함수호출
1) 정적함수 호출(정적함수)
2) 객체로 멤버함수 호출(멤버함수)
3) 객체의 주소로 멤버함수 호출(멤버함수)
=> 함수 포인터 선언과 함수 포인터를 통한 호출은 위 세가지 호출 방식에 따라 차이를 보임.

 

# C++에서의 함수포인터
1. 정적함수 대상 함수 포인터 : 선언과 호출이 우리가 일반적으로 알고 있는 것과 동일(즉, C와 동일)

2. 멤버함수 대상 함수 포인터 :
e.g.    void Point::Print(int n); => void (Point::*pf)(int); : 함수 포인터 선언
1) 객체로 멤버함수 호출시 => (pt.*pf)(10);
2) 객체의 주소로 멤버함수 호출시 => (pt->*pf)(10);

# 서버와 클라이언트

- 어떤 기능이나 서비스를 제공하는 코드 => 서버
- 서버의 기능을 제공받는 코드 => 클라이언트
- 일반적으로 서버는 하나지만, 서버를 사용하는 클라이언트는 여러개.
- 클라이언트가 서버를 호출하면 콜(call) - 일반적인 경우
- 서버가 클라이언트를 호출하면 콜백(callback)

# 콜백 매커니즘을 구현하려면 클라이언트가 서버를 호출(콜)할 때,
서버에 클라이언트 정보를 제공해야 한다.
- 정책은 클라이언트에 의해 결정되며 클라이언트만이 알고 있다.
- 서버는 추상화되며, 구체적인 작업은 클라이언트에서 이루어진다.

# 함수 객체(Functor)
- 함수처럼 호출이 가능한 클래스 객체
- 즉, 함수 호출 연산자 () 오버로딩이 된 클래스의 객체
- 함수처럼 사용할 수 있으면서도 상태를 가질 수 있기 때문에 STL에서는 함수 객체를 선호함
- STL의 많은 알고리즘이 클라이언트 정책을 반영하기 위해 클라이언트 함수를 호출함(콜백)
- 장점

1) 멤버변수와 멤버함수를 가질 수 있고,

2) Functor의 서명이 같더라도 객체 타입이 다르면 서로 전혀 다른 타입으로 인식하며,
3) 속도도 일반함수보다 Functor가 빠름(Fuctor의 인라인화 가능, 컴파일러가 쉽게 최적화 가능)


[출처] : 윤성우 저, "열혈 C++ 프로그래밍", 오렌지미디어