I'm FanJae.

[C++] Operator Overloading IV 본문

C++/Basic

[C++] Operator Overloading IV

FanJae 2024. 8. 26. 14:26

※ 본 포스트는 코드누리 C++ Basic 강의 내용을 보고 정리한 포스트입니다.

 

1. -> 연산자 재정의를 통한 Smart Pointer

#include <iostream>

class Car
{
    int color;
public:
    ~Car() { std::cout << "~Car"; }
    void go() { std::cout << "Car Go"};
};

class Ptr
{
    Car* obj;
public:
    explicit Ptr(Car* p = nullptr) : obj{p} {}
    ~Ptr() { delete obj;}
    
    Car* operator->() { return obj;}
    Car& operator*() { return *obj;}
};

int main()
{
    Ptr p ( new Car ) ; // p는 객체, 타입 Ptr
    
    // Ptr의 객체 p가 Car* 역활을 수행
    p->go();         // p.operator->()go()
                     // (p.operator->())->go() 
    
    (*p).go();
}

- 이 예제를 보면, Ptr의 객체인 p가 마치 Car*의 역활을 수행하고 있는 것을 볼 수 있다.

- 이처럼, 다른 타입의 포인터 역활을 하는 객체가 스마트 포인터이다.

- p->go();와 같이 불렀을때, 컴파일러가 다음과 같이 변환하여 호출하는 것이다.

(p.operator->())->go()

- (*p).go();와 같이 불렀을때는, 컴파일러가 다음과 같이 변환하여 호출한다.

(p.operator*()).go()

 

 

스마트 포인터(Smart Pointer)의 원리와 주의 사항

원리 : 연산자와 * 연산자를 재정의 해서 객체를 포인터처럼 사용가능 하게 만든 것

주의사항 :  * 연산자는 반드시 Reference(Car &)로 반환 (임시 객체가 생성되지 않게 만들기 위해서)

 

② Smart Pointer를 사용하는 이유

Car* p = new Car; // 사용후 delete 해야함.

- 이와 같이 할당을 받은 경우에는 반드시 소멸자를 호출해줘야 한다.

- 하지만, Smart Pointer는 객체다. 즉, 블록을 벗어날 때 소멸자가 호출된다.

 

스마트 포인터는 객체이므로, 생성/복사/대입/소멸의 모든 과정에서 추가 작업을 수행할 수 있다.

※ 일반적으로, 대표적인 활용이 소멸자에서 객체를 자동으로 삭제하는 기능이다.

 

1-1. 기존 Ptr의 문제점 개선

class Ptr
{
    Car* obj;
public:
    explicit Ptr(Car* p = nullptr) : obj{p} {}
    ~Ptr() { delete obj;}
    
    Car* operator->() { return obj;}
    Car& operator*() { return *obj;}
};

- 지금 이 Ptr은 Car에 대해서만 받을 수 있다. 따라서, 이를 모든 타입에 대해 활용 가능하도록 Template화를 진행한다.

 

#include <iostream>
template <typename T>
class Ptr
{
    T* obj;
public:
    explicit Ptr(T* p = nullptr) : obj{p} {}
    ~Ptr() { delete obj;}

    T* operator->() { return obj;}
    T& operator*() { return *obj;}
};

int main()
{
    Ptr<int> p1(new int);
    *p1 = 10;
    std::cout << *p1;
}

- 이를 활용하면 delete를 하지 않아도 알아서 자원을 회수한다.

- 실제 스마트 포인터를 구현하기 위해서는 여러 기능들을 더 구현해야 한다.

- 하지만, 이미 C++ 표준에서 제공하는 스마트 포인터가 존재한다.

 

1-2. std::shared_ptr

- C++ 표준에서 제공하는 스마트 포인터

- <memory> 헤더

#include <iostream>

class Car
{
    int color;
public:
    ~Car()    { std::cout << "~Car" << std::endl; }
    void go() { std::cout << "Car Go" << std::endl; }
};

int main()
{
    //  std::shared_ptr<Car> sp1 = new Car; // error
    std::shared_ptr<Car> sp2(new Car);  // ok
    std::shared_ptr<Car> sp3{new Car};  // ok
    
    sp2->go();
}

- std::shared_ptr<Car> sp1 = new Car;와 같은 것을 허용하지 않는다.

- 즉, 이는 Explicit 생성자 형태임을 알 수 있다.

- std::shared_ptr을 사용하면, 우리가 직접 구현했던 스마트 포인터처럼 자원 회수에 신경쓰지 않아도 된다.

- shared_ptr은 기본으로 다루기에는 개념이 복잡하여, STL 파트에서 따뤄 나눠서 다루고자 한다.


2. 함수 객체(Function Object)

struct Plus
{
    int operator()(int a, int b) const
    {
        return a+b;
    }
}
int main()
{
    Plus p;    // p는 함수가 아니라 객체.
    
    int n1 = p(1, 2); // 객체 p를 함수 처럼 사용
                      // p.operator(),(1, 2)
                      
    int n2 = p.operator()(1, 2);

}

 - () 연산자를 재정의 해서 특정 객체를 함수처럼 사용할 수 있다면, 이것을 함수 객체(Function Object)라고 한다.

 

2-1. 예제의 개선

template <typename T>
struct Plus
{
    T operator()(const T& a, const T& b) const
    {
         return a + b;
    }
};

int main()
{
    Plus p;    // p는 함수가 아니라 객체.
    
    int n1 = p(1, 2); // 객체 p를 함수 처럼 사용
                      // p.operator(),(1, 2)
                      
    int n2 = p.operator()(1, 2);

}

 

2-2. 함수 객체를 사용하는 것의 이점

- 상태를 가지는 함수

- 클로져 ( Closure )

- 인라인 치환성

- 이름 충돌 방지 ( ADL 회피 )

상태를 가지는 함수 외에는 다소 어려운 개념이 많아서 Basic에서 다루기는 부적합하다고 한다.

 

2-3. 상태를 가지는 함수란?

#include <iostream>
#include <cstdlib>

int urand()
{
    return rand() % 10;
}

int main()
{
    for ( int i = 0; i < 10; i++)
        std::cout << urand() << ", ";
}

- 위 예제는 0 ~ 9 사이의 임의의 난수를 발생시키는 예지이다.

- 만약 이것이 중복되지 않는 난수를 반환하라고 가정했다고 생각해보자.

 

① 일반 함수의 문제점

- 보통, 일반 함수는 동작이 있지만 상태라는 것은 존재하지 않는다.

- 즉, 함수가 실행 중에 생성된 데이터를 보관할 장소가 없다.

- 전역변수 등에 보관해야 하는데 전역변수를 사용하는 것은 일반적으로 좋은 코드가 아니다.

 

※ 이에 반면, 함수 객체는 동작 뿐 아니라 상태도 가질 수 있다.

 

2-3-1. 함수 객체의 이점

#include <iostream>
#include <cstdlib>

class URandom
{
public:
    int operator()()
    {
        return rand() % 10;
    }
};
URandom urand;

int main()
{
    for ( int i = 0; i < 10; i++ )
          std::print( "{}, ", urand() );
}

- 함수 객체는 operator() 연산자 함수 뿐 아니라, 멤버 데이터, 생성자 등을 가질 수 있다.

- 상태를 가질 수 있다는 얘기는 즉, 멤버 데이터를 가질 수 있다는 뜻이다.

 

① std::bitset<> template

- 비트를 관리하는 C++ 표준(STL)이 제공하는 타입(템플릿)

#include <iostream>
#include <bitset>
#include <string>

int main()
{

    std::bitset<10> bs;

    bs.set(5);   // 5번째 비트만 1로
    bs.set();    // 모든 비트를 1로
    bs.reset(3); // 3번째 비트만 0으로

    std::cout << bs.test(3) << std::endl;
    std::cout << bs.test(5) << std::endl;

    std::cout << bs.to_string() << std::endl;
}

- 결과는 아마 이와 같을 것이다.

- 이를 활용하면, 중복 되지 않는 난수를 발생하는 것이 가능할 것이다.

 

2-3-2. 중복되지 않는 난수 발생기의 완성

#include <iostream>
#include <cstdlib>
#include <bitset>

class URandom
{
     std::bitset<10> bs;
     bool recycle;
public:
    URandom(bool b = false) : recycle{b}
    {
        bs.set(); // 10 비트 모두를 1로 둔다.
    }
    int operator()()
    {   
        if ( bs.none() )
        {
             if ( recycle )
             {
                  bs.set();
             }
             else
                  return -1;
        } 
        int v = -1;
        while( ! bs.test(v = rand() % 10) );
        bs.reset(v);
        
        return v;
    }
};
URandom urand;

int main()
{
    for ( int i = 0; i < 10; i++ )
          std::cout << urand() << ", ";
}

 

 

while( ! bs.test(v = rand() % 10) );

- 이 로직은 1이 되어 있는 부분을 찾기 위해서 반복한다.

        if ( bs.none() )
        {
             if ( recycle )
             {
                  bs.set();
             }
             else
                  return -1;
        }

- 다음과 같이 적어줌으로써, 사용자가 재사용을 원할 경우, 다시 10비트를 모두 1로 바꿔서, 재사용을 가능하게 한다.

 

2-4. C++ 표준에서 제공하는 함수 객체

#include <iostream>
#include <functional>

int main()
{
    std::plus<int>    f1;
    std::minus<int>   f2;
    std::modulus<int> f3; // %
    
    std::cout << f1(10,3) << std::endl; // 13
    std::cout << f2(10,3) << std::endl; // 7
    std::cout << f3(10,3) << std::endl; // 1
}

- 이처럼, () 연산을 재정의 하여 함수를 객체처럼 사용 가능하다.


3. 인자 기반 탐색(Argument Dependent Lookup)

namespace Graphics
{
    class Point {};
    
    void draw_pixel(const Point& p1) {}
    
    void set_color(int c) {}
}

int main()
{
    Graphics::Point pt;
    Graphics::draw_pixel(pt);
    
    draw_pixel(pt);
}

- namespace 안에 있는 daw_pixel를 호출 하지만 아무런 문제가 없다.

 

① ADL(Argument Dependent Lookup) 이란?

함수를 검색할 때 인자가 포함된 namespace는 자동으로 검색에 포함되는 개념이다.

- Koening Lookup이라고도 부른다. (만든 사람이 Andrew Kening이라서)

- 즉, Point는 Graphics에 있기 때문에 draw_pixel을 찾을때, Graphics를 자동으로 검색한다는 것이다.

 

3-1. ADL을 만든 이유

namespace std;
{
    class string{};
    
    string operator+(const string& s1, const string& s2)
    {
         return s1; // 정확하지 않은 구현
    }
}
int main()
{
    std::string s1;
    std::string s2;
    
    s1 + s2; // operator+(s1, s2)
    operator+(s1, s2);
}

 - 분명 STL을 명시해 놓은적이 없지만, 정상적으로 실행이 된다.

- 이것이 되는 이유가 ADL을 지원하기 때문이다.

- 만약 이 상황에서 ADL이 없다고 생각해보자.

 

① ADL이 없으면 어떻게 될까?

std::operator+(s1,s2);

- std::string 객체에 대해서 s1 + s2처럼 사용하는건 불가능하다. namespace를 적을 방법이 없다.

- ADL 덕분에 namespace 안에 있는 타입에 대해서 다양한 연산자 재정의 함수를 사용할 수 있다.

 

② C++ 표준 함수에서의 ADL

#include <algorithm>
#include <string>

int main()
{
    int n1 = 10;
    int n2 = 20;
    
    std::string s1 = "AAA";
    std::string s2 = "BBB";
    
    std::swap(n1, n2);
    std::swap(s1, s2);
    
    swap(n1, n2); // error
    swap(s1, s2); // ok
}

- swap(n1, n2); 에 대해서는 에러가 발생한다. 이는, int는 std에 없다. string은 std에 있기에 가능하지만 말이다.

- 함수 인자가 std 이름 공간안에 있는 타입의 객체라면 std::를 붙이지 않아도 사용 가능하다.

- 하지만, 가독성을 위해서 되도록 붙이는 것이 좋다.

'C++ > Basic' 카테고리의 다른 글

[C++] STL Container II  (0) 2024.08.27
[C++] STL Container I  (0) 2024.08.27
[C++] Operator Overloading III  (0) 2024.08.25
[C++] Operator Overloading II  (0) 2024.08.24
[C++] Operator Overloading I  (0) 2024.08.23
Comments