[전문가를 위한 C++] 클래스와 객체 완전 정복
‘전문가를 위한 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)를 정의해야 한다.
- 컴파일러는 다음과 같을 때 이동 생성자와 이동 대입 연산자를 사용한다.
- 원본 객체가 임시 객체로 되어 있어 연산 수행 후 자동으로 제거될 때
- 명시적으로 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