개발 이야기/[스터디] c++

[A Tour of C++] Chapter 5. Essential Operations

경이로운아일라 2024. 8. 26. 18:02
이 글은 <A Tour of C++ Second Edition, Bjarne Stroustrup>를 참고하여 작성하였습니다.

 

[A Tour of C++] Chapter 5. Essential Operations

이번 장에서는 C++에서 중요한 의미를 가지는 연산자들, 특히 생성자(constructor), 소멸자(destructor), 복사(copy), 이동(move) 연산자에 대해 설명합니다. 이러한 연산자들은 객체의 수명과 자원 관리를 담당하는 핵심 요소들로, 올바르게 정의하고 사용하면 효율적이고 안전한 프로그램을 작성할 수 있습니다.

 

5.1 Essential Operation

우리가 하나의 클래스를 새로 정의할 때, 메모리 관리 차원에서 사용할 수 있는 기본 연산 종류는 아래와 같습니다.

// 우리가 모두 아래 연산을 따로 정의하지 않는다면
// 컴파일러가 정해진 기본 방식대로 아래 연산들을 정의한다
class X {
	public:
		X(Sometype);               // 생성자
		X();                       // 기본 생성자
		X(const X&);               // copy 생성자
    X(X&&);                    // move 생성자
		X& operator=(const X&);    // copy 대입
		X& operator=(X&&);         // move 대입
		~X();                      // 소멸자
}

 

=default

C++ 11에선 default 키워드를 통해 “컴파일러가 알아서 만들어주는 것들을 사용한다” 라는 의도를 명확히 전달해줄 수 있습니다. (컴파일러가 알아서 만들어주는 생성자는 얕은 복사 방식을 사용합니다.)

class Foo
{
public:
   ...
	 // "깊은 복사를 하지 않아도 되므로 컴파일러가 구현해주는 디폴트 함수 및 연산자를 사용하겠다"
	 Foo(const Foo& other) = default;
   Foo& operator=(const Foo& rhs) = default;
   ...
};

=delete

C++ 11에선 delete 키워드를 통해 “컴파일러가 알아서 만들어주는 것들을 사용하지 않겠다” 라는 의도를 명확히 전달해줄 수 있습니다.

class Foo
{
public:
   ...
	 // 복사 생성자를 호출하면 Error 발생
   Foo(const Foo& other) = delete;
   Foo& operator=(const Foo& rhs) = delete;
   ...
};

explicit

explicit 키워드는 자신이 원하지 않은 형변환이 일어나지 않도록 제한하는 키워드입니다.

class Vector { 
	public:
		explicit Vector(int s);
	// ... 
};

Vector v1(7); // OK
Vector v2 = 7; // ERROR: explicit 키워드가 int->Vector 형변환을 막는다

Member Initializers

클래스의 member initialization에 기본 값을 해두면, 코드가 간단해지고 실수로 멤버를 초기화하지 않는 일도 막을 수 있습니다.

class complex { 
	double re = 0;
	double im = 0;
    // ...
}

 

5.2 Copy and Move

기본적으로 컴파일러는 멤버별 복사(=shallow copy)를 지원합니다.

Copying Containers

하지만 vector 같이 pointer를 통해 자원을 관리하는 타입의 경우 shallow copy가 적절하지 않은 케이스도 있습니다. 아래처럼 v2[0]도 2가 되는 것을 막고자 한다면, copy constructor, copy assignment가 deep copy를 할 수 있도록 사용자가 직접 정의해주어야 합니다.

void bad_copy(Vector v1) {
	Vector v2 = v1; // copy
	v1[0] = 2; // v2[0] = 2
}

 

Moving Containers

크기가 큰 객체라면 가능하다면 copy보다는 move가 낫습니다. 아래 코드에서는 + 연산자의 리턴이 복사를 호출하지 않도록 move constructor/assignment를 정의해두는 것이 낫습니다.

void f(const Vector& x, const Vector& y, const Vector& z) {
	Vector r; 
	// + 연산자의 리턴이 복사라면 2번의 복사 발생: 비효율적
	r = x+y+z;
}

 

RVO(Return Value Optimization)

RVO(Return Value Optimization)는 C++ 컴파일러가 함수 반환 시 불필요한 복사를 제거하는 최적화 기법입니다. C++11 이후, 대부분의 컴파일러가 RVO를 지원하여, 반환 값의 복사 비용을 줄여줍니다.

  • AS-IS: 함수 내부에서 지역 변수에 값을 생성한 후, 그 값을 반환할 때, 일반적으로 복사가 발생합니다.
  • TO-BE: 컴파일러가 반환할 메모리 공간에 직접 값을 생성함으로써 복사를 제거하고 최적화합니다.

 

5.3 Resource Management

C++에서 자원 관리는 매우 중요한 요소입니다. C++의 자동 메모리 관리 기법과 스마트 포인터에 대해서 알아보겠습니다.

Memory Resource Management

 

  • 가비지 컬렉션(Garbage Collection, GC): 힙 영역에 있는 객체들 중 더 이상 참조되지 않는 객체를 주기적으로 해제하는 메모리 관리 기법입니다. C++ 자체에는 GC가 내장되어 있지 않지만, 일부 라이브러리를 통해 개별적으로 사용할 수도 있습니다.
  • 참조 카운팅(Reference Counting): 객체에 대한 참조가 없어지면 객체를 해제하는 방식입니다. C++11 이후 shared_ptr은 참조 카운팅을 통해 자원 관리를 지원합니다.

 

Smart Pointer

스마트 포인터는 사용이 끝난 메모리를 자동으로 해제해 줍니다. 대표적인 스마트 포인터는 다음과 같습니다:

  • unique_ptr: 하나의 객체만 소유하며, 소유권을 명시적으로 이전할 수 있습니다는 특징이 있습니다.
  • shared_ptr: 여러 객체가 동일한 자원을 소유하며, 참조 카운팅을 사용해 자원을 관리합니다는 특징이 있습니다.
  • weak_ptr: shared_ptr의 순환 참조 문제를 해결하기 위해 사용됩니다. 자세한 내용은 13장에서 이어집니다.

 

5.4 Conventional Operation

C++에서는 특정 연산자와 함수들에 관례적인 의미가 있으며, 이를 오버로딩할 때는 이러한 관에 맞게 구현하는  것이 중요합니다


관례적인 의미가 있는 연산자 예시

  • 비교: ==, !=, <, <=, >, and >=
  • Container operations: size(), begin(), and end()
  • Input and output operations: >> and <<
  • User-defined literals
    • 사용자가 정의한 접미사를 이용해 원하는 타입의 객체를 쉽게 생성
  • swap()
  • Hash functions: hash<X>