2020년 6월 23일 화요일

C# 객체에서의 unmanged/manged resource 처리 관련 (Dispose pattern)

예전에 C# RAII(Resource Acquisition Is Initialization) 게시물에서 적기는 했었는데

C# garbage collection의 정책상 object instance가 scope을 벗어나거나 더이상 사용되지 않더라도 바로 destory되지 않으므로
- 명시적인 resource 해제를 위한 IDisposable 인터페이스를 구현하고
- using문을 사용하여 object가 관리(생성,사용,삭제)되도록 해야 한다.


아래는 IDisposable interface에 대한 내용인데 Dispose pattern을 소개하고 있다. 

IDisposable interface

Implement a Dispose method


Dispose pattern 예제 코드이다. 영어 주석을 간략히 한글로 달았음.

    public class MyResource: IDisposable
    {
        // 외부에서 전달받은 unmanaged 리소스
        private IntPtr handle;
        // 내부에서 생성하는 managed 리소스
        private Component component = new Component();
        // Dispose가 호출 되었음을 알려주는 flag
        private bool disposed = false;

        public MyResource(IntPtr handle)
        {
            this.handle = handle;
        }

        // 리소스를 해제하기 위한 method
        // 하위 class에서 재정의 하지 못하도록 virtual이 아님
        public void Dispose()
        {
            Dispose(true);
            // object가 위 Dispose메서드 호출로 정리되지만 
            // object가 finalization queue에 포함되어 종료자/finalizer가 호출되지 않도록 
            // 아래 명령어를 호출한다.
            GC.SuppressFinalize(this);
        }

        // 아래 함수가 호출되는 경우는 두가지인데
        // 첫번째는(disposing == true) 사용자의 코드를 통해 호출되는 경우, 이 때는 managed/unmanaged 리소스를 해제해야 한다.
        // 두번째는(disposing == false) finalizer가 호출한 상황으로 다른 obejct가 어떤상황인지 모르므로 unmanaged resource만 해제해야 한다.
        protected virtual void Dispose(bool disposing)
        {
            if(!this.disposed)
            {
                if(disposing)
                {
                    // 첫번째 경우 managed 리소스 해제
                    component.Dispose();
                }
                // 첫번째, 두번째 경우 unmanaged 리소스 해제
                CloseHandle(handle);
                handle = IntPtr.Zero;
                // dispose가 호출되었음을 기록
                disposed = true;
            }
        }

        // unmanaged 리소스를 해제하기 위한 interop
        [System.Runtime.InteropServices.DllImport("Kernel32")]
        private extern static Boolean CloseHandle(IntPtr handle);

        // finalization을 위한 종료자/finalizer(소멸자/destructor)
        // Dispose()가 호출되면 아래 코드는 호출되지 않는다.
        ~MyResource()
        {
            Dispose(false);
        }
    }


위 Dispose pattern을 따라 구현하면
- Dispose()를 명시적으로 호출하여 자원을 해제하게 하거나
  사용자가 Dispose() 호출을 잊어버렸을 때 자원을 해제할 수 있게 한다.
- Dispose()를 여러번 호출해도 문제가 없음
- 종료자/finalizer 가 호출 될 때 object가 Dispose가 된 상태라도 문제가 없다.

하지만 이 pattern이 자원 해제와 메모리 해제의 결합도를 가지게하여 종료자 스레드의 부담을 증가한다는 점이 있다고 한다.

함께 위 패턴을 사용함에 있어 Dispose 구현을 사용하게 하려면 Using 문이나 Try/Finally 블록을 사용하면 된다. 어차피 Using이 Try/Finally 생성처럼 동작한다고 함.

Using block has three parts: acquisition, usage, and disposal.

  • Acquisition means creating a variable and initializing it to refer to the system resource. The Using statement can acquire one or more resources, or you can acquire exactly one resource before entering the block and supply it to the Using statement. If you supply resourceexpression, you must acquire the resource before passing control to the Using statement.

  • Usage means accessing the resources and performing actions with them. The statements between Using and End Using represent the usage of the resources.

  • Disposal means calling the Dispose method on the object in resourcename. This allows the object to cleanly terminate its resources. The End Using statement disposes of the resources under the Using block's control.


만약 부모 클래스를 상속하는 자식 클래스가 있다면 자식 클래스에서는 다음과 같은 패턴을 구현해야 한다고 함.

Subclasses should implement the disposable pattern as follows:

  • They must override Dispose(Boolean) and call the base class Dispose(Boolean) implementation.

  • They can provide a finalizer if needed. The finalizer must call Dispose(false).


아래 예제에서는 종료자/finalizer는 구현하지 않는 자식클래스 예제임.

class DerivedClass : BaseClass
{
   bool disposed = false;
   SafeHandle handle = new SafeFileHandle(IntPtr.Zero, true);

   protected override void Dispose(bool disposing)
   {
      if (disposed)
         return;

      if (disposing) {
         handle.Dispose();
         // 다른 managed 리소스를 해제
      }

      // unmanaged 리소스를 해제

      disposed = true;
      // 부모 클래스의 Dispose(bool)를 호출
      base.Dispose(disposing);
   }
}


그리고 아래 책을 보면

C# 6.0 완벽 가이드 깊고 넓게 알려주는 레퍼런스 북 [ 전2권 ]
조셉 앨버허리, 벤 앨버허리 공저 / 류광 역 | 인사이트(insight) 

종료자/finalizer에 대한 언급이 있다.

객체에 종료자/finalizer가 있다면 인스턴스의 메모리 해제 시 종료자/finalizer가 호출된다. 
. 종료자/finalizer가 없다면 객체는 garbage collection 시 바로 삭제되지만
  종료자/finalizer가 있다면 별도의 대기열에 저장하여 관리하며 종료자/finalizer 호출 전까지 객체가 유지된다. 
. 종료자 때문에 메모리할당과 garbage collection이 느려진다. (종료자 객체들의 관리 및 처리)
. 종료자는 객체와 객체가 참조하는 객체의 수명을 필요 이상으로 늘린다.
. 종료자의 호출 순서를 예측할 수 없고 시점을 제어할 수 없다.
. 종료자안에서 코드 실행이 차단되면 다른 객체의 종료자가 호출되지 못한다.
. app이 제대로 종료되지 않는다면 종료자들이 호출되지 않을 수 있다.

그래서 더더욱 resource 해제를 위해서라면 위 Dispose pattern을 구현하는것이 맞겠다.


마지막으로 Garbage collection에 대해 설명하는 공식 문서

2020년 6월 10일 수요일

C++ 최적화 책, 6장 동적 할당 변수 최적화

C++ 최적화 책을 보게 되었고 읽으며 기억해야 할 내용을 간략히 정리 함.
C++ 언어를 사용하시거나 더 자세한 내용을 보시려면 아래 책을 꼭 읽어보세요. 오랜만에 읽는 유익한 C++ 책이네요.

C++ 최적화 최고 성능을 구현하는 10가지 검증된 기법
커트 건서로스 저/옥찬호 역/박수현 감수 | 한빛미디어

C++ 최적화 책, 4장 문자열 최적화
http://charlie0301.blogspot.com/2020/05/c-4.html


C++ 변수

- C++ 변수는 고정된 레이아웃을 가지고 컴파일 타임에 크기가 결정됨
- 변수는 저장 기간(수명)을 가짐
- 정적 저장 기간
 . 컴파일러가 예약해둔 메모리에 상주
 . 메모리 주소는 컴파일 타임에 결정
 . 전역 정적 변수는 main()함수 진입 전에 생성, main()함수를 떠난뒤 파괴
 . 함수에서 선언한 정적 변수는 함수에 처음 진입하기 전에 생성
 
- 스레드 지역 저장 기간
 . C++ 11 이후 thread-local storage(TLS) 변수를 선언 가능
 . 스레드 진입 시 생성, 스레드가 끝나면 파괴
 . thread_local 키워드로 선언된 변수는 스레드 지역 저장 기간을 가짐

- 자동 저장 기간
 . 함수 호출 스텍에서 컴파일러가 예약해둔 메모리 상주
 . 컴파일 타임에 메모리 크기 결정
 . 함수 호출 스택 포인터에서 고정된 오프셋 위치를 가짐
 . 코드 블록 {} 안에 존재하므로 {} 진입 시 생성, 빠져나오면 파괴

- 동적 저장 기간
 . 실행 중인 프로그램에서 요청한 메모리에 상주
 . 프로그램에서 new로 명시적으로 저장 공간을 요청하고 delete로 파괴
 . 메모리 관리자가 메모리 관리
 . 자동 변수와 마찬가지로 주소는 런타임에 결정


- 변수의 소유권( 변수의 생성, 파괴를 결정하는 주체를 설명하기 위한 개념인듯)
 : 전역 소유권
  . 프로그램이 가지며 main()함수 진입 전 생성되고 main()함수를 떠난 뒤 파괴 됨.
 : 유효 범위가 지정된 소유권 
  . 중괄호로 둘러 싸인 코드블록의 유효 범위가 소유권을 가짐. 
  . main() 함수의 {} 도 해당 되며 main()함수에서 선언된 자동 변수는 정적 변수와 동일한 수명을 가짐
 : 멤버 소유권 
  . 클래스 인스턴스가 가짐. 
  . 클래스 인스턴스가 생성될 때 생성되며 인스턴스가 파괴될 때 파괴됨.
 : 동적변수의 소유권 
  . 정해져 있지 않고 프로그램에서 명시적으로 관리해야 함. 
  . new로 만들어진 포인터를 프로그램이 관리하고 더이상 사용하지 않는 경우 delete로 제거해야 함. (포인터 날로 쓰는게 가장 싫었음. 가능하면 동적 변수는 스마트 포인터로 관리하는게 맞는 방법임.)


- C++11의 nullptr이라는 특정한 값은 유효한 메모리를 가지키지 않는다. 
  : 0을 nullptr로 변환할 수 있지만 0 != nullptr 이다.



POCO(Plain Old C++ 개체)에 대한 포인터를 캡슐화하는 데 가장 먼저 스마트 포인터를 사용합니다.

  • unique_ptr
    기본 포인터로 한 명의 소유자만 허용합니다. shared_ptr이 필요하다는 점을 확실히 알 경우 POCO의 기본 선택으로 사용합니다. 새 소유자로 이동할 수 있지만 복사하거나 공유할 수 없습니다. 사용하지 않는 auto_ptr을 대체합니다.boost::scoped_ptr과 비교합니다. unique_ptr작고 효율적입니다. 크기는 하나의 포인터이며 C++ 표준 라이브러리 컬렉션에서 빠른 삽입 및 검색을 위한 rvalue 참조를 지원합니다. 헤더 파일: <memory>. 자세한 내용은 unique_ptr 인스턴스 및 unique_ptr 클래스를 만드는 방법( 사용 방법)을 참조하십시오.

  • shared_ptr
    참조 횟수가 계산되는 스마트 포인터입니다. 원시 포인터 하나를 여러 소유자에게 할당하려고 할 경우 사용합니다(예: 컨테이너에서 포인터 복사본을 반환할 때 원본을 유지하고 싶을 경우). 원시 포인터는 모든 shared_ptr 소유자가 범위를 벗어나거나 소유권을 포기할 때까지 삭제되지 않습니다. 크기는 2개의 포인터입니다. 하나는 개체용이고, 다른 하나는 참조 횟수가 포함된 공유 제어 블록용입니다.헤더 파일: <memory>. 자세한 내용은 인스턴스 및 shared_ptr 클래스를shared_ptr 만드는 방법: 인스턴스 만들기 및 사용 방법을 참조하십시오.

  • weak_ptr
    shared_ptr과 함께 사용할 수 있는 특별한 경우의 스마트 포인터입니다.weak_ptr은 하나 이상의 shared_ptr 인스턴스가 소유하는 개체에 대한 액세스를 제공하지만, 참조 수 계산에 참가하지 않습니다. 개체를 관찰하는 동시에 해당 개체를 활성 상태로 유지하지 않으려는 경우 사용합니다. shared_ptr 인스턴스 사이의 순환 참조를 차단하기 위해 필요한 경우도 있습니다. 헤더 파일: <memory>. 자세한 내용은 weak_ptr 인스턴스 및 weak_ptr 클래스를 만드는 방법: 인스턴스 만들기 및 사용 방법을 참조하십시오.



스마트 포인터의 삭제 조건
- break, continue를 만났을 때, 함수에서 값을 리턴할 때, 예외가 발생하는 등 선언문을 둘러싸고 이쓴 범위를 빠져 나갈 때 소유한 동적 변수 삭제
- 클래스 멤버로 선언된 스마트 포인터는 클래스 인스턴스가 파괴될 때 소유한 동적 변수를 삭제함.
- 스레드 지역 저장 기간으로 선언된 스마트 포인터 인스턴스는 스레드가 정상 종료될 때 소유한 동적 변수를 삭제
 : 운영체제가 스레드를 종료시킨 경우 일반적으로 삭제되지 않는 다고 함.
- 정적 저장 기간으로 선언된 스마트 포인터의 인스턴스는 프로그램이 종료될 때 소유한 동적 변수 삭제

- 동적 변수를 한 소유자가 관리하는 경우 
 : std::unique_ptr 사용 추천. 비용 패널티도 거의 없음.

스마트 포인터는 메모리 및 성능 관점에서 최대한 효율적으로 사용할 수 있도록 설계되었습니다. 예를 들어, unique_ptr의 유일한 데이터 멤버는 캡슐화된 포인터입니다. 즉, unique_ptr은 해당 포인터와 정확히 동일한 크기(4바이트 또는 8바이트)입니다. 오버로드된 스마트 포인터 * 및 -> 연산자(s)를 사용하여 캡슐화된 포인터에 액세스하는 것은 원시 포인터에 직접 액세스하는 것보다 훨씬 느리지 않습니다.


- 동적 변수를 여려명이 공유하는 경우, 두개 이상의 포인터가 동적변수를 관리하는 경우 접근 시 유효성을 확인하기 어렵고 삭제 같은 관리가 어려움. 
 : std::shared_ptr를 사용하는 것을 추천
 : 단 shared_ptr에 대입할 때 new 표현식에서 리턴되는 것을 바로 넣고 std::make_shared를 사용하는 것을 추천

- 동적 변수는 런타임 비용이 있음.
- 동적 변수의 메모리를 할당하기 위해서는 수천번의 메모리 접근이 필요할 때도 있음.
 : 동적 변수 생성을 위해 메모리 할당 함수는 빈 메모리 블럭을 찾음.
  . 빈 메모리 블록을 찾으면 블록을 제거한 뒤 제공
  . 필요한 것보다 큰 블록을 찾으면 분할한 뒤 일부를 반환
 : 이용 가능한 메모리 블록이 없다면 할당 함수는 큰 메모리 블럭을 추가로 얻기 위해 가용 메모리 시스템 풀에서 운영체제 커널을 고비용으로 호출
  . 커널에서 사용하는 메모리는 RAM에 캐시되어 있을 수도 없을 수도 있음. 캐시되어 있지 않다면 지연 될 수 있음
 : 빈 메모리 블록의 컬렉션은 프로그램의 모든 스레드가 공유하는 자원이며 메모리 블록 변경 사항은 스레드 세이프(thread-safe) 함
  . 할당, 해제 함수를 자주 호출하면 하나 외 다른 스레드들이 대기하게 됨
 : 동적 변수의 할당된 메모리를 해제할 때 반환된 블럭을 빈 메모리 컬렉션에 넣게 되지만 복잡함.
  . 할당, 해제로 블록들의 크기가 작아지는 것을 방지하기 위해 해제된 메모리 블록을 인접한 메모리 블록과 합치려고 시도함. 


동적 변수 사용 줄이기

- 클래스 인스턴스를 정적으로 만들어라
  ex) YourClass yourInstance("hahaha",111);

- 클래스 멤버 변수를 정적으로 만들어라
 : 클래스 멤버변수가 클래스 인스턴스라면 클래스 생성 시 멤버변수도 정적으로 생성되어 할당하는 비용을 줄일 수 있음.
 : 클래스 생성 시 멤버 변수를 생성하기 위한 자원이 없어 동적으로 생성해야 하는 경우 두 단계초기화(two-part initialization)을 사용하라.

- 배열의 크기가 정해져 있다면 std::vector 대신 std::array를 사용하라.
 : std::array는 복사 생성할 수 있고 operator[]로 임의 접근 반복자와 첨자 지정을 제공함.

- 저장공간이 커지면서 발생하는 재할당 비용을 줄이기 위해 스택에 큰 버퍼를 만들어라

- 정적으로 초기화된 데이터가 연결 자료 구조라면 정적으로 구성하라.

- 이진트리를 배열로 만들어라

- std::deque 대신 원형 버퍼(http://bit.ly/b-buffer)를 사용하라.

- 변수 할당 비용을 줄이기 위해 new 대신 std::make_shared를 사용하라.
  ex) std::shared_ptr p = std::make_shared("hahah",111);
        auto p = std::make_shared("hahah",111);

- 동적 변수를 사용한다면 std::unique_ptr, std::shared_ptr을 사용하라.


동적 변수의 재할당 줄이기

- 동적 변수를 미리 할당하여 재할당을 방지하라.
 : 메모리할당 비용을 줄이기 위해 std::string, std::vector의 reserve(size_t n) 같은 함수로 적당히 공간을 확보하라.
- 반복문 바깥에서 동적 변수를 만들어라
 : 반복문내의 변수는 할당/해제를 반복하므로 외부에 두고 clear()같은 함수를 사용하여 초기화 후 사용하라.


불필요한 복사 제거하기

- 클래스의 대입 시(ClassA = ClassB)의 대입 연산자(Assignment operator)가 호출 됨. 이 때 클래스 멤버에 따라서 대입하는 비용이 상당히 커질 수 있음.
- 선언과 동시에 초기화 하는 문장(Foo a = b)의 경우 Foo가 클래스라면 복사생성자(Copy constructor)가 호출 됨. 
 : 대입 연산자와 복사 생성자는 하는일이 거의 동일하고 비용이 클 수 있어 최적화 시 다음상황에서 발생가능한지 확인해야 함.

- 초기화 (생성자 호출)
- 대입 (대입 연산자 호출)
- 함수 인수 (함수의 인자/형식 인수로 전달되면서 이동 생성이나 복사 생성됨)
- 함수 반환 (이동 생성자나 복사 생성자를 호출)
- 표준 라이브러리 컨테이너에 항목을 삽입 (항목은 이동 생성이나 복사 생성됨)
- 벡터에 항목을 삽입 (벡터가 재할당 될 경우 모든 항목은 이동 생성이나 복사 생성됨)

- 클래스 정의에서 원치 않는 복사 방지하기
 : 클래스 인스턴스를 복사하는 비용이 많거나 복사를 원하지 않는다면 복사를 금지할 수 있음.
 : 단 아래와 같이 복사를 금지한다면 표준 라이브러리 컨테이너 클래스 값으로 사용하지 못함.

// C++ 11 이전 방법
class BigClass {
private :
   BigClass(BigClass const&);
   BigClass& operator=(BigClass const&);
public : 
...
};

// C++11 이후
class BigClass {
public 
   BigClass(BigClass const&) = delete;
   BigClass& operator=(BigClass const&) = delete;
   ...
};


- 함수 호출에서 복사 제거하기, 
 : 함수 인자로 넘겨지는 클래스 인스턴스의 경우 레퍼런스로 받아라. 
 : 내부에서 수정이 필요 없으면 const &, 수정이 필요하면 &
 : 하지만 함수 내에서 레퍼런스값을 여러번 참조한다면 역참조(dereferencing)로 인한 비용이 더 커질 수 있음.

포인터나 레퍼런스에서 값을 확인할 때를 역참조라 표현


- 함수 반환에서 복사 제거하기
 : 함수에서 클래스 인스턴스를 리턴 시 반환값으로 복사 생성됨.
 : 하지만 C++ 컴파일러에서는 복사 생략(copy elision), 반환값 최적화(return value optimization, RVO) 방법을 사용하지만 구체적인 조건에서만 가능함.
   . 함수는 내부에서 생성된 객체를 반환해야 함.
   . 함수에서 선언했던 반환 타입과 똑같은 타입이어야 함.
   . 함수가 간단하고 제어 경로가 하나뿐이라면 컴파일러는 RVO를 수행할 가능성이 높음
 : 그 외 방법으로는 출력용 매개변수(out parameter)를 사용하여 값을 반환하는 것도 방법임.
  ex) vector scalar_product(std::vector const& v, int c) 
        =>  void scalar_product(std::vector const& v, int c, vector& result)
 

RVO(Return Value Optimization)
 Output (note that -fno-elide-constructors disables RVO in clang):
$ clang++ -std=c++11 main.cpp && ./a.out
c'tor
d'tor

$ clang++ -std=c++11 -fno-elide-constructors main.cpp && ./a.out
c'tor
move c'tor
d'tor
move c'tor
d'tor
d'tor
RVO 적용과 미적용의 차이를 확실히 알 수 있다.


- COW(Copy On Write) 구현하기
 : COW의 개념은 원본 객체와 복사한 객체 중 하나가 수정되기 전까지 객체가 동일하다는 것
    즉 초기에는 얕은 복사(shallow copy)를 하고 객체가 수정될 때까지 깊은 복사(deep copy)를 지연 시키는 것


COW(Copy On Write)
예시를 보여주고 있지만 주의점도 함께 설명하고 있다.

n a multi-threaded environemnt (which is most of them nowadays) CoW is frequently a huge performance hit rather than a gain. And with careful use of const references, it's not much of a performance gain even in a single threaded environment. 
Additionally, as other people have pointed out, CoW strings are really tricky to implement, and it's easy to make mistakes. That coupled with their poor performance in threading situations makes me really question their usefulness in general. This becomes even more true once you start using C++11 move construction and move assignment. 
답변의 내용이 생각보다 부정적인 반응임. 구현하기 까다롭고 성능 개선도 크지 않을 수 있다고 함.


이동 문법 구현하기
책의 내용을 참고하자. 어렵지만 이 책(https://www.oreilly.com/library/view/effective-modern-c/9781491908419/)도 참고하면 좋다.


평평한(flat) 자료구조
- 자료 구조 요소들이 인접한 저장 공간에 저장되었다면 자료구조가 평평하다고 표현함.
- 평평한 자료구조는 생성 시 포인터로 연결된 자료구조보다 메모리 관리자를 호출하는 횟수가 적음
- list, deque, map, unordered_map 자료구조는 동적 객체를 많이 만드는 반면 vector는 이보다 적게 만듬. 같은 big O 성능을 갖는다고 하더라도 평평한 자료구조가 가지는 장점이 많음.
- std::array, std::vector와 같은 평평한 자료구조는 list, map, unordered_map과 같은 노드 기반의 자료구조보다 메모리를 적게 차지함.
- 평평한 자료구조를 사용하면 스마트 포인터와 스마트 포인터가 가리키는 객체를 할당하는 런타임 비용을 없앨 수 있음.

2020년 6월 4일 목요일

C++ class 선언 시 소멸자, 복사 생성자(copy constructor), 이동 생성자(move constructor) 처리, The rule of three/five/zero

C++에서는 기본적인 내용인데 간과하기 쉬운게 class 생성시 member 변수의 형식에 따라서 생성자, 소멸자, 연산자를 처리해줘야 한다. 기본적인 생성자, 소멸자, 복사 생성자/할당 연산자, 이동 생성자/할당 연산자에 대해서는 아래 MSDN을 참고하면 자세하고 친절히 설명해준다.

- 생성자(constrctor), 소멸자(destructor)

- 복사 생성자(copy constructor), 복사 할당 연산자(copy assign operator)

- 이동 생성자(move constructor), 이동 할당 연산자(move assignment operator)


예를 들어 class member 변수가 pointer와 같이 복사, 이동 시 관리되지 않는 변수 형식이라면 class가 생성, 소멸, 복사, 이동 시 해당 member 변수가 정상적으로 할당, 삭제, 복사, 이동 될 수 있도록 해줘야 한다.

왜나면 C++에서는 특정 조건에 따라 컴파일러가 class의 생성자, 복사 생성자, 복사 할당 연산자, 소멸자를 자동으로 생성하게 되어 의도치 않게 member 변수가 복사, 이동 될 수 있음.

"C++ 얕은복사 vs 깊은복사" 검색어나 "C++ shallow copy vs deep copy" 검색어로 검색하면 관련 글들이 꽤 나온다.


그래서 C++ class 선언과 관련된 The rule of three/five/zero 같은 용어가 있음.

간단히 말하면 class 선언 시 member 변수의 처리에 따라 3개, 5개, 0개의 생성자, 연산자를 정의하라는 것이고 간략히 내용을 발췌하면 다음과 같음.

아래 Rule of three 코드에서 복사 시 제대로 관리되지 않는 char* 형의 cstring member 변수가 있다.
만약 복사 생성자를 정의하지 않아 컴파일러가 복사 생성자를 만든다면 해당 member 변수는 복사 시 얕은 복사(shallow copy)가 되어 member 변수의 pointer 주소만 복사되어 두 class instance는 동일한 메모리를 바라보게 된다. 프로그램 실행 중 한 instance가 소멸되면 다른 instance의 변수가 영향을 받는다.

그래서 아래 코드에서는 3개(복사 생성자(copy constructor), 복사 할당 연산자(copy assignment), 소멸자) member 함수를 정의 하여 깊은 복사(deep copy)를 하여 복사된 class instance가 복사한 class instance와 다른 저장공간의 member 변수를 가지도록 한다.

일반적으로 관리되지 않는 member 변수 특히나 char* 형식의 단순 문자열을 가지고 있는 경우 아래와 같이 정의하여 처리한다.

Rule of three

class rule_of_three
{
    char* cstring; // raw pointer used as a handle to a dynamically-allocated memory block
 
    void init(const char* s)
    {
        std::size_t n = std::strlen(s) + 1;
        cstring = new char[n];
        std::memcpy(cstring, s, n); // populate
    }
 public:
    rule_of_three(const char* s = "") { init(s); }
 
    ~rule_of_three()
    {
        delete[] cstring;  // deallocate
    }
 
    rule_of_three(const rule_of_three& other) // copy constructor
    { 
        init(other.cstring);
    }
 
    rule_of_three& operator=(const rule_of_three& other) // copy assignment
    {
        if(this != &other) {
            delete[] cstring;  // deallocate
            init(other.cstring);
        }
        return *this;
    }
};


아래 코드에서는 동일한 상황이지만 class instance가 이동 생성자(move constructor), 이동 할당 연산자(move assignment operator)가 사용되는 상황을 처리하기 위해 5개(이동 생성자(move constructor), 이동 할당 연산자(move assignment operator), 복사 생성자(copy constructor), 복사 할당 연산자(copy assignment), 소멸자) member 함수를 정의하였음.

예제의 swap 함수를 보면 알겠지만 할당된 메모리양이 많은 char* member 변수를 가지고 있는 경우 class instance 이동 시 performance를 고려하고자 이동 생성자, 할당 연산자를 정의하는 경우가 있다고 함.

Rule of five

class rule_of_five
{
    char* cstring; // raw pointer used as a handle to a dynamically-allocated memory block
 public:
    rule_of_five(const char* s = "")
    : cstring(nullptr)
    { 
        if (s) {
            std::size_t n = std::strlen(s) + 1;
            cstring = new char[n];      // allocate
            std::memcpy(cstring, s, n); // populate 
        } 
    }
 
    ~rule_of_five()
    {
        delete[] cstring;  // deallocate
    }
 
    rule_of_five(const rule_of_five& other) // copy constructor
    : rule_of_five(other.cstring)
    {}
 
    rule_of_five(rule_of_five&& other) noexcept // move constructor
    : cstring(std::exchange(other.cstring, nullptr))
    {}
 
    rule_of_five& operator=(const rule_of_five& other) // copy assignment
    {
         return *this = rule_of_five(other);
    }
 
    rule_of_five& operator=(rule_of_five&& other) noexcept // move assignment
    {
        std::swap(cstring, other.cstring);
        return *this;
    }
 
// alternatively, replace both assignment operators with 
//  rule_of_five& operator=(rule_of_five other) noexcept
//  {
//      std::swap(cstring, other.cstring);
//      return *this;
//  }
};

class의 member 변수가 복사, 이동 시 관리되는 경우라면 컴파일러가 생성하는 default 생성자, 연산자를 사용하도록 지정한 예제 코드들이다. 처음 코드 처럼 아무것도 선언하지 않거나 두번째 코드 처럼 default로 지정해도 class 생성, 복사, 이동 시 처리되는 결과는 동일한다.

C++로 개발하고 파일 operation 처리와 같이 byte stream을 처리해야 하는 경우가 아닌 단순 문자열을 처리하는 상황, performance가 critical한 요소가 아니라면 std::string을 사용하는게 당연해 보인다.

Rule of zero

class rule_of_zero
{
    std::string cppstring;
 public:
    rule_of_zero(const std::string& arg) : cppstring(arg) {}
};

class base_of_five_defaults
{
 public:
    base_of_five_defaults(const base_of_five_defaults&) = default;
    base_of_five_defaults(base_of_five_defaults&&) = default;
    base_of_five_defaults& operator=(const base_of_five_defaults&) = default;
    base_of_five_defaults& operator=(base_of_five_defaults&&) = default;
    virtual ~base_of_five_defaults() = default;
};


하지만 컴파일러가 자동으로 생성하는 것을 고려한다면 class 선언이 다음 규칙을 따르도록 해야 한다. 괜히 어설프가 일부 생성자, 연산자를 정의하면 자동으로 생성되지 않을 수 있어 곤란해질 수 있다.


 따라서 단순 형식에는 편리하지만 복합 형식은 종종 하나 이상의 특수 멤버 함수 자체를 정의하므로 다른 특수 멤버 함수가 자동으로 생성되지 않도록 할 수 있습니다. 실제로는 다음과 같습니다.
  • 생성자가 명시적으로 선언된 경우 기본 생성자가 자동으로 생성되지 않습니다.

  • 가상 소멸자가 명시적으로 선언된 경우 기본 소멸자가 자동으로 생성되지 않습니다.

  • 이동 생성자 혹은 이동 할당 연산자가 명시적으로 선언된 경우 다음과 같습니다.

    • 복사 생성자가 자동으로 생성되지 않습니다.

    • 복사 할당 연산자가 자동으로 생성되지 않습니다.

  • 복사 생성자, 복사 할당 연산자, 이동 생성자, 이동 할당 연산자 또는 소멸자가 명시적으로 선언된 경우 다음과 같습니다.

    • 이동 생성자가 자동으로 생성되지 않습니다.

    • 이동 할당 연산자가 자동으로 생성되지 않습니다.

 참고

또한 C++ 11 표준은 다음 추가 규칙을 지정합니다.

  • 복사 생성자나 소멸자가 명시적으로 선언된 경우 복사 할당 연산자가 자동으로 생성되지 않습니다.
  • 복사 할당 연산자나 소멸자가 명시적으로 선언된 경우 복사 생성자가 자동으로 생성되지 않습니다.

두 경우 모두 Visual Studio에서는 필요한 함수가 암시적으로 자동 생성되며 경고를 생성하지 않습니다.


참고로 아래 rule 같이 하나를 정의할 거면 모두 정의하던지 delete 처리하라던지 조언 하기도 함.

C.21: If you define or =delete any default operation, define or =delete them all