15 minute read

‘전문가를 위한 C++ - Marc Gregoire 지음, 남기혁 옮김’ 책을 참고하여 작성한 포스트입니다.


프렌드

  • 다른 클래스, 다른 클래스의 멤버 함수, 다른 클래스의 비 멤버 함수가 클래스 내의 protected, private 데이터 멤버와 메서드에 접근 가능하게 해주는 기능
  • 프렌드 기능은 캡슐화 원칙에 위배되므로 필요한 경우에만 최소한으로 사용해야 한다.



객체에 동적 메모리 할당하기

Spreadsheet 클래스

  • 이 장에서도 Spreadsheet 클래스를 이용하여 설명한다.
  • size_t 타입은 <cstddef> 헤더에 정의되어 있다.
  • 동적 메모리를 살펴보기 위해 일반 포인터를 사용한다.
Spreadsheet 클래스 구현 코드
#include <stddef>

class Spreadsheet
{
public:
	Spreadsheet(size_t Width, size_t Height);
	~Spreadsheet(); // 소멸자는 예외를 던짐 안된다!
	Spreadsheet(const Spreadsheet& src);
	Spreadsheet& operator=(const Spreadsheet& rhs);
	void Swap(Spreadsheet& Other) noexcept;
	void SetCellAt(size_t X, size_t Y, const SpreadsheetCell& Cell);
	SpreadsheetCell& GetCellAt(size_t X, size_t Y);
	void VerifyCoordinate(size_t X, size_t Y) const;
private:
	size_t Width { 0 };
	size_t Height { 0 };
	SpreadsheetCell** Cells { nullptr };
};
export void Swap(Spreadsheet& First, Spreadsheet& Second) noexcept;

Spreadsheet::Spreadsheet(size_t Width, size_t Height)
	: Width (Width), Height(Height)
{
	Cells = new SpreadsheetCell*[Width];
	for (size_t i = 0; i < Width; i++)
	{
		Cells[i] = new SpreadsheetCell[Height];
	}
}

Spreadsheet::~Spreadsheet()
{
	for (size_t i = 0; i < Width; i++)
	{
		delete[] Cells[i];
	}
	delete[] Cells;
	Cells = nullptr;
}

// 위임 생성자 이용한 복제 생성자
Spreadsheet::Spreadsheet(const Spreadsheet& src)
	: Spreadsheet { src.Width, src.Height } 
{
	for(size_t i = 0; i < Width; i++)
	{
		for (size_t j = 0; j < Heigth; j++)
		{
			Cells[i][j] = src.Cells[i][j];
		}
	}
	// 기존 Cells를 삭제할 필요는 없다.
	// 복제 생성자이므로 Cells가 현재 객체에 남아있지 않다.
}

void Spreadsheet::Swap(Spreadsheet& Other) noexcept
{
	std::swap(Width, Other.Width);
	std::swap(Height, Other.Height);
	std::swap(Cells, Other.Cells);
}

void Swap(Spreadsheet& First, Spreadsheet& Second) noexcept
{
	First.Swap(Second);
}

Spreadsheet& Spreadsheet::operator=(const Spreadsheet& rhs)
{
/*
	// 자신을 대입하는 지 확인
	if (this == &rhs)
	{
		return *this;
	}

	// 기존 메모리 해제
	for(size_t i = 0; i < Width; i++)
	{
		delete[] Cells[i];
	}
	delete[] Cells;
	Cells = nullptr;

	// 메모리 새로 할당
	this.Width = rhs.Width;
	this.Height = rhs.Height;

	Cells = new SpreadsheetCell*[Width];
	for (size_t i = 0; i < Width; i++)
	{
		Cells[i] = new SpreadsheetCell[Height];
	}

	// 데이터 복제
	for(size_t i = 0; i < Width; i++)
	{
		for (size_t j = 0; j < Heigth; j++)
		{
			Cells[i][j] = rhs.Cells[i][j];
		}
	}
	return *this;
*/

	// 모든 작업을 임시 인스턴스에서 처리
	Spreadsheet Temp { rhs };
	// 예외를 던지지 않는 연산에서만 작업 처리
	Swap(Temp);
	return *this;
}

void Spreadsheet::VerifyCoordinate(size_t X, size_t Y) const
{
	if(X >= Width)
	{
		throw out_of_range { format("{} must be less than {}.", X, Width)};
	}
	if(Y >= Height)
	{
		throw out_of_range { format("{} must be less than {}.", Y, Height)};
	}
}

void Spreadsheet::SetCellAt(size_t X, size_t Y, const SpreadsheetCell& Cell)
{
	VerifyCoordinate(X, Y);
	Cells[X][Y] = Cell;
}

SpreadsheetCell& Spreadsheet::GetCellAt(size_t X, size_t Y)
{
	VerifyCoordinate(X, Y);
	return Cells[X][Y];	
}

복제와 대입 처리하기

대입 연산자

  • int, 포인터와 같은 기본 타입에 대해서는 비트 단위 복제(bitwise copy, shallow copy)나 대입(assignment)이 적용된다.
  • 하지만 동적으로 할당한 메모리의 경우 얕은 복제를 하면 원래 데이터가 아닌 포인터의 복제본만 받는다.
  • 이 경우 원래의 포인터가 가리키던 메모리를 해제할 경우 복제된 포인터는 더 이상 올바른 메모리를 가리키지 않는 댕글링 포인터(dangling pointer)가 된다.
  • 그리고 포인터만 복제 되어버리는 경우 가리키던 메모리가 미아가 되어 메모리 누수(memory leak)가 발생할 수 있다.
  • 그래서 복제 생성자와 대입 연산자는 반드시 깊은 복제(deep copy)를 적용해야 한다.
  • 컴파일러가 자동으로 생성하는 디폴트 복제 생성자나 대입 연산자는 원본 객체의 데이터 멤버를 대상 객체로 단순히 복제하거나 대입하기만 하므로,
  • 동적 할당 메모리가 있는 경우 직접 정의해 주어야 한다!
  • 위 코드에서 대입 시에 반복문을 돌게 되는데, 반복문에서 예외가 발생하면 메서드 내의 코드를 건너뒤고 리턴해 버리게 되고, 객체가 비정상적인 상태가 된다.
  • 해결 방법 중 하나는 복제 후 맞바꾸기 구문(copy-and-swap idiom)이다.
  • 이 구문을 안전하게 구현하려면 예외를 던지면 안되므로 noexcept로 지정한다.

대입과 값 전달 방식 금지

  • 클래스에서 메모리를 동적으로 할당할 때 아무도 이 클래스의 객체에 복제나 대입을 할 수 없게 하기도 하는 간편한 방법이 있다.
  • operator=와 복제 생성자를 명시적으로 삭제하면 된다.
  • 구현할 필요도 같이 없어진다.


이동 의미론(move semantics)으로 이동 처리하기

  • 객체에 이동 의미론을 적용하려면 이동 생성자(move constructor)이동 대입 연산자(move assignment operator)를 정의해야 한다.
  • 컴파일러는 다음과 같을 때 이동 생성자와 이동 대입 연산자를 사용한다.
    1. 원본 객체가 임시 객체로 되어 있어 연산 수행 후 자동으로 제거될 때
    2. 명시적으로 std::move() 호출하여 삭제될 때
  • 메모리를 비롯한 리소스의 소유권을 다른 객체로 이동 시킨다.
  • 멤버 변수에 대한 얕은 복제와 비슷하지만 소유권을 전환함으로써 댕글링 포인터나 메모리 누수를 방지한다.
  • 이동 후 원본 객체에 있는 데이터 멤버를 널값으로 꼭 초기화할 필요는 없으나,
  • unique_ptr과 shared_ptr은 표준 라이브러리에서 nullptr로 초기화하도록 명시하고 있다.

우측값 레퍼런스

  • 좌측값(lvalue)
    • 이름 있는 변수처럼 주소를 가질 수 있는 대상
  • 우측값(rvalue)
    • 좌측값이 아닌 나머지
    • 리터럴, 임시 객체, 값 등
  • 우측값 레퍼런스(rvalue reference)
    • 특히 우측 값이 임시 객체이거나 std::move()로 명시적으로 이동된 객체일 때 적용됨
    • 오버로딩된 함수 중 우측값에 적용할 대상을 결정하는 데 사용됨
    • 크기가 큰 객체 복사 연산이 오더라도 임시 객체라는 점을 이용해 포인터를 복사하는 방식으로 처리 가능
    • 함수의 매개변수에 &&를 붙여 우측값 레퍼런스로 만든다.
    • 일반적으로 임시 객체는 const type&로 취급하지만 함수의 오버로딩 버전 중 우측값 레퍼런스를 사용하는 함수가 있다면 그 버전으로 임시 객체를 처리한다.
    • 우측값 레퍼런스로 전달된 인수는 임시 객체이므로 함수 안에서 변경한 사항들은 함수 리턴 후 사라진다.
    • 리터럴도 우측값이므로 우측값 레퍼런스 버전이 호출되나, const 레퍼런스 매개변수로 인수 전달이 가능하긴 하다.
void HandleMessage(string& Message)
{
	cout << "Handle Message with lvalue reference, " << Message << endl;
}

void HandleMessage(string&& Message)
{
	cout << "Handle Message with rvalue reference, " << Message << endl;
}

string a = "Hello ";
// lvalue 
HandleMessage(a);
string b = "World";
// rvalue
HandleMessage(a + b); 
// rvalue
HandleMessage("Hello World");
  • 좌측값 오버로딩이 되지 않은 함수에 이름 있는 변수(좌측값이죠)를 인수로 호출하려면 컴파일 에러가 발생한다.
  • std::move() 를 이용하면 된다.
    • move()는 좌측값을 우측값 레퍼런스로 캐스트해준다.
    • 실제로 이동시키는 작업은 하지 않음.
void Helper(std::string&& Message) {}
void HandleMessage(std::string&& Message)
{
	// Message가 이름 있는 변수라 좌측값이다.
	// 따라서 아래는 컴파일 에러 발생.
	Helper(Message); 

	// 우측값으로 캐스트 해주어야 한다.
	Helper(std::move(Message));
}
  • 우측값 레퍼런스에 임싯값을 대입하면 우측값 레퍼런스가 스코프에 있는 동안 존재한다.
int& i = 2; // error : 상수에 대한 레퍼런스
int a = 2, b = 3;
int& j = a + b; // error : 임시 객체에 대한 레퍼런스

int&& i = 2;
int a =2, b = 3;
int&& j = a + b;

이동 의미론 구현 방법

  • 이동 의미론은 우측값 레퍼런스로 구현하는데, 클래스의 경우에는 이동 생성자이동 대입 연사자를 구현해 주어야 한다.
  • 표준 라이브러리와 호환성을 유지하려면 이동 생성자와 이동 대입 연산자를 noexcept로 지정해 주어야 한다.
  • 이동 의미론을 구현하고 예외도 던지지 않는다고 보장되어야 객체를 이동시킬 수 있다.
class Spreadsheet
{
public:
	Spreadsheet(Spreadsheet&& src) noexcept; // 이동 생성자
	Spreadsheet& operator=(Spreadsheet&& rhs) noexcept; // 이동 대입 연산자
	...
private:
	void Cleanup() noexcept;
	void MoveFrom(Spreadsheet& src) noexcept;
	...
}

...

void Spreadsheet::Cleanup() noexcept
{
	for (size_t i=0; i < Width; i++)
	{
		delete[] Cells[i];
	}
	delete Cells;
	Cells = nullptr;
	Width = Height = 0;
}

void Spreadsheet::MoveFrom(Spreadsheet& src) noexcept
{
	// shallow copy
	Width = src.Width;
	Height = src.Height;
	Cells = src.Cells;

	// 소유권을 이전했으므로 소스 객체 리셋
	src.Width = 0;
	src.Height = 0;
	Cells = nullptr;
}

// 이동 생성자
Spreadsheet::Spreadsheet(Spreadsheet&& src) noexcept
{
	MoveFrom(src);
}

// 이동 대입 연산자
Spreadsheet& Spreadsheet::operator=(Spreadsheet&& rhs) noexcept
{
	if (this != &rhs)
	{
		// 예전 메모리 해제
		Cleanup();
		MoveFrom(rhs);
	}
	return *this;
}
  • 복제 생성자, 복제 대입 연산자, 소멸자, 이동 대입 연산자를 직접 선언하지 않으면 컴파일러가 디폴트 이동 생성자를 만들어 준다.
  • 복제 생성자, 복제 대입 연산자, 소멸자, 이동 생성자를 직접 선언하지 않으면 컴파일러가 디폴트 이동 대입 연산자를 생성한다.
  • 즉, 다섯 가지 중 하나라도 직접 선언 했다면 나머지 모두를 선언해야 하는데, 이를 5의 법칙(rule of five)이라고 한다.
  • 모두 구현하거나, default or delete 해주자.

std::exchange()

  • <utility> 에 정의된 exchange()는 기존 값을 새 값으로 교체한 후 기존 값을 리턴하는데, 이동 대입 연산자 구현 시 유용하다.
// 첫 매개변수를 변수에 대입하고, 두번째 매개변수를 첫 매개변수에 대입한다.
Width = exchange(src.Width, 0);
Height = exchange(src.Height, 0);
Cells = exchange(src.Cells, nullptr);

객체 데이터 멤버 이동하기

  • 데이터 멤버가 기본 타입이면 위처럼 해주어도 되지만, 객체인 경우는 std::move()를 이용해주면 된다.
    • Name = std::move(src.Name);
  • 코드 재사용 및 버그 최소화를 위해 다음과 같이 이미 구현한 Swap 함수를 이용해주면 좋다.
  • 이동생성자가 정의되어 있으면 컴파일러는 객체를 복제하지 않고 이동시키고, 깊은 복제를 수행하지 않아도 되는 장점이 있다.
  • vector에서 용량이 꽉차 새로운 메모리를 할당하고 기존 원소를 옮기는 경우, 이동 생성자가 정의되어 있으면 복제가 아닌 이동 생성자를 호출한다.
  • 복제 생성자와 복제 대입 연산자는 메모리를 새로 할당하고 복제하는 반면 이동 생성자를 호출하는 경우에는 메모리를 할당할 필요가 없어 효율적이다.
// 이동 생성자
Spreadsheet::Spreadsheet(Spreadsheet&& src) noexcept
{
	Swap(*this, src);
}

// 이동 대입 연산자
Spreadsheet& Spreadsheet::operator=(Spreadsheet&& rhs) noexcept
{
	Swap(*this, rhs);
	return *this;
}

Spreadsheet 이동 연산자 테스트

Spreadsheet CreateObject()
{
	return Spreadsheet {3, 2};
}

int main()
{
	vector<Spreadsheet> Vec;
	// 1. Spreadsheet 객체 생성
	// 2. 이동생성자를 호출하여 vector 내로 이동
	Vec.push_back(Spreadsheet { 100, 100});
	// 3. Spreadsheet 객체 생성
	// 4. 벡터의 용량이 꽉차 메모리를 새로 할당하고 이동성자를 호출하여 기존 원소들을 옮김
	// 5. 새로운 객체의 이동생성자를 호출하여 vector 내로 이동
	Vec.push_back(Spreadsheet { 100, 100});
	// 6. 새로운 객체 S1 생성
	// 7. CreateObject()를 통해 임시 객체 생성
	// 8. 임시 객체는 대입 후 사라지기 때문에 이동 대입 연산자 호출
	Spreadsheet S1 { 2, 3 };
	S1 = CreateObject();
	// 9. 새로운 객체 S2 생성
	// 10. S1은 이름 있는 객체이므로 복제 대입 연산자 호출
	// 11. 그러기 위해서 임시 복제본 객체 생성
	// 12. 복제 생성자를 통해 복제 작업 수행
	Spreadsheet S2 { 4, 5 };
	S2 = S1;
}

이동 의미론으로 swap 함수 구현하기

// 함수의 이동 의미론 적용 x
// T가 복제하기 상당히 무거우면 성능이 크게 떨어짐
template <typename T>
void SwapCopy(T& A, T& B)
{
	T Temp = A;
	A = B;
	B = Temp;
}

// 함수의 이동 의미론 적용 O
// 표준 라이브러리의 swap 함수가 아래와 같이 구현되어 있음
template <typename T>
void SwapMove(T& A, T& B)
{
	T Temp { std::move(A) }; 
	A = std::move(B);
	B = std::move(Temp);
}

return 문에서 std::move() 사용하기

return object;

  • 위 구문에서 object가 로컬 변수거나, 함수에 대한 매개변수거나, 임싯값이라면 우측값 표현식으로 취급 된다.
    • RVO(return value optimization) 적용
  • object가 로컬 변수면
    • NRVO(named return value optimization) 적용
  • 둘 다 복제 생략(copy elision)으로, 함수에서 객체를 리턴하는 과정을 복제나 이동시키지 않고 처리해 효율적이다.
  • 이를 통해 영복제 값 전달 의미론(zero-copy pass-by-value semantics) 구현이 가능하다.
  • 이동 의미론 보다 더 효율적이므로, 함수에서 로컬 변수나 매개변수를 리턴할 때는 std::move()가 아닌 return object를 사용하자.
  • 객체의 데이터 멤버 리턴 시에는 적용되지 않고,
  • return condition ? object1 : object2; 구문은 return object가 아니라 적용되지 않는다.

함수에 인수를 전달하는 최적의 방법(나중에 추가)




메서드의 종류

static 메서드

  • 객체가 아닌 클래스 단위로 적용되는 메서드
  • static 메서드는 특정 객체에 대한 정보에 접근하지 않는다.
  • 그래서 this 포인터를 가질 수 없고, 객체의 비 static 멤버에 접근할 수도 없다.
  • private static이나 protected static에는 접근 가능하다.
  • 타입이 같은 객체의 비 static private, protected 멤버에 접근하게 하는 방법은 있다. 객체를 포인터나 레퍼런스 타입의 매개변수로 전달하면 됨.
  • 클래스 안에 있는 메서드는 static 메서드를 일반 함수처럼 호출할 수 있고, 클래스 밖의 static 메서드는 스코프 지정 연산자(::)를 사용해 호출하면 된다.

const 메서드

  • 객체나 객체의 레퍼런스, 객체의 포인터에 const를 붙이면 const 키워드를 붙인 메서드만 호출 가능하다.
  • 메서드에 const 키워드를 붙인다는 의미는 해당 메서드가 데이터 멤버를 변경하지 않는다고 보장한다는 의미다.
  • 구현 코드에서도 const 키워드 적어 두어야 한다.
  • static 메서드는 애초에 클래스의 인스턴스를 가질 수 없으므로, 인스턴스 내부의 값을 변경하는 것이 가능하지 않아 const 키워드로 선언할 수 없다.
  • const로 선언하지 않은 객체에 대해서는 const, 비 const 메서드 둘다 호출 가능하다.

mutable 데이터 멤버

  • const로 정의한 메서드에서 객체의 데이터 멤버를 변경하고 싶을 때 사용한다.
  • 예를 들어 GetValue() 호출 횟수를 알고 싶을 때,
int SpreadsheetCell::GetValue() const
{
	NumAccesses++;
	return CellValue;
}

메서드 오버로딩(overloading)

  • 이름은 같고 매개변수 타입이나 개수가 다르게 메서드나 함수를 여러 개 정의하는 것
  • 어떤 오버로딩 된 메서드나 함수를 호출할 지 결정하는 것을 오버로딩 결정(overload resolution)이라고 한다.
  • 리턴타입만 다른 경우는 오버로딩이 지원 되지 않는다.

const 기반 오버로딩

  • const 기준으로도 오버로딩이 가능하다.
  • 그러면 컴파일러는 const 객체에 대해서는 const 버전 메서드를, 비 const 객체에 대해서는 비 const 버전 메서드를 호출한다.
  • 만약 const, 비 const 버전 메서드의 구현 코드가 동일한 경우는 const_cast() 패턴을 적용하면 좋다.
const SpreadsheetCell& Spreadsheet::GetCellAt(size_t X, size_t Y) const
{
	VerifyCoordinate(X, Y);
	return Cells[X][Y];	
}

SpreadsheetCell& Spreadsheet::GetCellAt(size_t X, size_t Y)
{
	return const_cast<SpreadsheetCell&>(as_const(*this).GetCellAt(X, Y));
}
  • <utility> 헤더에 정의된 as_const()를 이용하여 *this를 const Spreadsheet&으로 캐스팅한다.
  • const 버전의 GetCellAt()을 호출하고, 리턴 받은 const SpreadsheetCell&을 const_cast()를 이용하여 비 const SpreadsheetCell&로 캐스팅해준다.

명시적으로 오버로딩 제거하기

  • double 매개변수를 받는 메서드가 있을 때 int 매개변수가 들어오는 경우 매개변수가 double로 변환되어 호출하는 것을 막고 싶다면,
public:
	void SetValue(double Value);
	void SetValue(int) = delete;

참조 한정 메서드(ref-qualified method)

  • 특정한 메서드를 호출할 수 있는 인스턴스의 종류(임시 인스턴스 또는 정식 인스턴스)를 명시적으로 지정할 수 있다.
  • 해당 메서드에 참조 한정자(ref-qualifier)를 붙이면 된다.
  • 정식 인스턴스에 대해서만 호출 가능하게 하려면 메서드 헤더 뒤에 & 한정자를 붙이고,
  • 임시 인스턴스는 && 한정자.
string& ...
string&& ...

인라인 메서드(inline method)

  • 메서드를 호출하는 자리에 메서드 본문을 집어넣는 기능
  • 메서드 정의 코드 이름 앞에 inline 키워드를 붙이면 된다.
  • 인라인 메서드가 성능이 더 나쁘다고 판단되면 컴파일러는 무시할 수도 있다.
  • 또한 인라인 메서드에 대한 정의는 호출하는 소스 파일에 있어야 한다.
  • 고급 컴파일러는 클래스 정의와 같은 파일에 작성하지 않아도 메서드의 크기가 작으면 자동으로 인라인으로 처리한다.

디폴트 인수(default argument)

  • 매개변수에 디폴트 값을 지정하는 기능이다.
  • 반드시 오른쪽 끝에 있는 매개변수부터 시작해 중간에 건너뛰지 않고 연속적으로 나열해야 한다.
  • 모든 매개변수에 디폴트값을 지정한 생성자는 디폴트 생성자처럼 사용 가능하므로, 이 경우는 디폴트 생성자를 정의하지 않아야 한다.
  • 디폴트 인수를 사용하면 생성자 하나로 여러 오버로딩을 구현할 수 있다.



데이터 멤버의 종류

static 데이터 멤버

  • 데이터 멤버의 성격이 객체보다는 클래스에 가까운 경우에 사용한다.
  • 자신이 속한 클래스 범위를 벗어날 수 없다는 점에서 글로벌 변수와 비슷하다.
  • 일반적으로 static 클래스 멤버를 정의하면 이 멤버에 대한 공간을 할당하는 코드를 소스 파일에 작성해야 한다.
  • 초기화 하지 않으면 일반 변수나 데이터 멤버와 달리 디폴트값으로 초기화된다.
class Spreadsheet
{
	...
private:
	static size_t Counter;
};

// 디폴트 값인 0으로 초기화됨
size_t Spreadsheet::Counter;

인라인 변수

  • static 변수도 인라인(inline)으로 선언 가능하다.
  • 그러면 소스 파일에 공간을 따로 할당하지 않아도 된다.
private:
	static inline size_t Counter {0};

클래스 메서드에서 static 데이터 멤버 접근하기

  • 일반 멤버처럼 접근하면 된다.

메서드 밖에서 static 데이터 멤버 접근하기

  • public으로 선언하면 스코프 지정 연산자를 통해 접근 가능하다.
  • const static 데이터 멤버가 아니라면 public으로 선언하는 건 좋지 않다.

const static 데이터 멤버

  • 특정 클래스에만 적용되는 상수(클래스 상수, class constant)로 사용하기 좋다.
class Spreadsheet
{
public:
	static const size_t MaxWidth { 100 };
};

Spreadsheet::Spreadsheet(size_t Width)
	: Width(min(MaxWidth, Width)) {}
  • 매개변수의 디폴트값으로도 사용 가능하다.

레퍼런스 데이터 멤버

  • 다른 객체를 참조하는 경우 레퍼런스를 사용하는 것이 바람직하다?
  • 레퍼런스는 한 번 초기화하고나면 레퍼런스가 가리키는 객체를 변경할 수 없다.
  • 대입 연산자 구현 시 이 부분을 꼭 염두해 두어야 한다.
  • 레퍼런스 데이터 멤버도 const로 지정 가능하다.



중첩 클래스

  • 클래스 정의할 때 중첩 클래스, 구조체(struct), 타입 앨리어스(type alias, typedef), 열거 타입(enum)도 선언 가능하다.
  • 물론 해당 클래스의 스코프 내로 제한된다.
  • 클래스 내부의 클래스에는 스코프 지정 연산자를 통해 접근해야 하며, 메서드의 리턴 타입에도 적용 되지만, 매개변수에는 적용되지 않는다.
  • 클래스 내부에 전방선언 하고 구체적인 정의 코드는 따로 작성해도 된다.
  • 중첩 클래스를 private 이나 protected로 선언하면 중첩 클래스를 담고 있는 클래스에서만 접근이 가능하다.
  • 중첩 클래스는 중첩 클래스를 담고 있는 클래스의 public 멤버만 접근 가능하다.



클래스에 열거 타입 정의하기

class SpreadsheetCell
{
public:
	enum class Color { Red, Green, Blue};
private:
	Color MyColor { Color::Red };
}



연산자 오버로딩

SpreadsheetCell의 덧셈 구현

add 메서드

operator+ overloading

  • 덧셈 연산자(addition operator)를 이용하는 방법이다.
  • 덧셈 연산자를 만나면 다른 SpreadsheetCell 객체를 인수로 받는 operator+란 메서드가 있는지,
  • SpreadsheetCell 객체 두 개를 인수로 받는 operator+란 이름의 글로벌 함수가 있는 지 찾는다.
  • SpreadsheetCell ThirdCell { MyCell + AnotherCell } -> SpreadsheetCell ThirdCell { MyCell.operator+(AnotherCell) } 로 번역한다.

    암묵적 변환

  • 변환 생성자가 적당히 정의되어 있다면 아래와 같이 operator+를 정의해도 여러 매개변수 타입에 대해 덧셈 연산이 가능하다.
  • 물론 이럴 경우 암묵적 변환이 일어날 때 항상 임시 객체를 생성하기 때문에 성능이 떨어질 수 있다.
  • 타입마다 오버로딩 해주면 임시 객체 생성 없이 덧셈이 일어난다.

operator+를 글로벌 함수로 구현하기

  • 암묵적 변환의 경우에 덧셈 연산자를 기준으로 왼쪽은 꼭 SpreadsheetCell 객체가 와야 한다.
  • 하지만 operator+를 글로벌 함수로 만들면 특정 객체에 종속되지 않기 때문에 덧셈의 교환법칙을 구현할 수 있다.

c++에서는 연산자 우선순위를 바꿀 수 없다. 또한 새로운 연산자 기호를 정의하거나 연산자의 인수 개수를 변경할 수도 없다.

산술 연산자 오버로딩

  • 덧셈과 다 비슷하고, 나눗셈에서 0으로 나눌 때 예외를 던져야 한다는 점을 고려하면 된다.

축약형 산술 연산자의 오버로딩

  • 축약형 산술 연산자의 왼쪽에는 반드시 객체가 나와야 한다.
  • 그래서 글로벌 함수가 아닌 메서드로 구현해야 한다.
  • 연산자에 대해 일반 버전과 축약 버전을 모두 정의할 때는 코드 중복을 피하도록 축약형 버전을 기준으로 일반 버전을 구현하는 것이 좋다.??

비교 연산자 오버로딩

  • 연산자 기준 왼쪽 오른쪽 모두 암묵적 변환이 가능하도록 하기 위해서는 글로벌 함수로 구현해주는 것이 좋다.
class SpreadsheetCell
{
public:
	SpreadsheetCell Add(const SpreadsheetCell& Cell) const;
	SpreadsheetCell operator+(const SpreadsheetCell& Cell) const;
	SpreadsheetCell& operator+=(cosnt SpreadsheetCell& rhs);

};
// global function
export SpreadsheetCell operator+(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs);
export bool operator==(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs);

SpreadsheetCell SpreadsheetCell::Add(const SpreadsheetCell& Cell) const
{
	return SpreadsheetCell { GetValue() + Cell.GetValue() };
}

SpreadsheetCell SpreadsheetCell::operator+(const SpreadsheetCell& Cell) const
{
	return SpreadsheetCell { GetValue() + Cell.GetValue() };
}

SpreadsheetCell operator+(const SpreadsheetCell& lhs, const SpreadsheetCell* rhs)
{
	return SpreadsheetCell { lhs.GetValue() + rhs.Getvalue() };
}

SpreadsheetCell& SpreadsheetCell::operator+=(const SpreadsheetCell& rhs)
{
	SetValue(GetValue() + rhs.GetValue());
	return *this;
}
// 축약형 버전을 기준으로 일반 버전 구현
SpreadsheetCell operator+(const SpreadsheetCell& lhs, const SpreadsheetCell* rhs)
{
	auto Result { lhs };
	Result += rhs; // +=() 버전으로 전달
	return Result;
}

bool operator==(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs)
{
	return (lhs.GetValue() == rhs.GetValue());
}

c++20 에서는 연산자 오버로딩이 한결 편해졌다. (자세한 내용은 나중에 추가)

연산자 오버로딩을 지원하는 타입 정의하기

  • 이용하는 사람의 편의를 위해 연산자는 add가 아닌 + 같은 걸로 통일해주는 것이 좋다.



안정된 인터페이스 만들기

인터페이스 클래스와 구현 클래스

  • 인터페이스 클래스와 구현 클래스를 따로 정의하면 인터페이스를 보다 간결하게 구성하고, 구현 세부사항을 모두 숨겨 인터페이스를 변경없이 안정적으로 유지할 수 있다.
  • 인터페이스 클래스는 public 메서드를 제공하되 구현 클래스 객체에 대한 포인터를 갖는 데이터 멤버 하나만 정의한다.
  • 이를 핌플 이디엄(pimpl(private implementation) idiom, 핌플 구문), 또는 브릿지 패턴(bridge pattern)이라고 부른다.
  • 그러면 인터페이스 클래스 메서드는 단순히 구현 클래스 객체에 있는 동일한 메서드를 호출만 하므로,
  • 구현 클래스 객체의 메서드가 변하더라고 인터페이스 클래스 내의 메서드는 영향을 받지 않아 다시 컴파일할 일이 줄어든다.
  • 주의할 점은 인터페이스 클래스 내의 유일한 데이터 멤버를 구현 클래스에 대한 포인터로 정의해야지, 값 타입이면 구현 클래스가 변경될 때마다 다시 컴파일 해야 한다!
class Spreadsheet
{
	...
private:
	class Impl;
	std::unique_ptr<Impl> Implement;
};
  • 위 처럼 Impl 클래스를 private 중첩 클래스로 정의한다.
  • Spreadsheet 클래스는 Impl 인스턴스에 대한 포인터인 데이터 멤버 하나만 갖게 된다.
  • 구현 부분을 모두 Impl 로 옮겨주면 된다.
// Impl 클래스 정의
class Spreadsheet::Impl { ... }

// Impl 생성자
Spreadsheet::Impl::Impl(...) {...}
// 기타 구현 메소드
...
  • 소멸자는 구현 클래스에 정의해 주어야 한다.
  • Spreadsheet 클래스 정의에서는 Impl 정의하는 코드를 모르니까..
Spreadsheet::~Spreadsheet() = default;

인터페이스와 구현을 분리하는 대신 추상 인터페이스(abstract interface), 즉 가상 메서드로만 구성된 인터페이스를 정의하고 이를 구현하는 클래스를 따로 작성해도 된다.

Leave a comment