suyeongpark

Player가 아니라 Creator가 되려는 자/ suyeongpark@abyne.com

전문가를 위한 C++/ 클래스와 객체 마스터하기

friend

C++는 클래스 안에서 다른 클래스나 다른 클래스의 멤버 함수 또는 비멤버 함수를 friend로 선언하는 기능을 제공한다. friend로 지정한 대상은 이 클래스의 protected나 private 데이터 멤버와 메서드에 접근할 수 있다. (이런 거를 왜 만들어 놨지? 이런거는 설계가 너무 난잡해진다. 상속이나 interface, component를 이용할 것)

예컨대 Foo와 Bar라는 두 클래스가 있다고 하자. 그리고 다음과 같이 Bar 클래스를 Foo의 프렌드로 지정한다.

class Foo
{
friend class Bar;
// ...
}

이렇게 하면 Bar에 있는 모든 메서드는 Foo의 private이나 protected 데이터 멤버 및 메서드에 접근할 수 있다.

Bar에 있는 메서드 중 특정한 메서드만 프렌드로 만들기 원할 수 있다. 그럴 때는 다음과 같이 작성한다.

class Foo
{
friend void Bar::processFoo(const Foo& foo);
// ...
}

스탠드언론 함수도 클래스의 프렌드가 될 수 있다. 예컨대 Foo 객체에 있는 데이터를 콘솔에 출력하는 함수를 만든다고 하자. 이 함수를 Foo 클래스 밖에서 검사하는 모델로 만들려고 하는데, 제대로 검사하라면 이 객체의 내부 데이터 멤버의 값에 접근해야 한다. 이때 Foo 클래스 정의에 다음과 같이 dumpFoo() 함수를 프렌드로 만들면 된다.

class Foo
{
friend void dumpFoo(const Foo& foo);
// ...
}

이 클래스에서 friend 선언문은 함수 프로토타입의 역할을 한다. 이렇게 지정한 프로토타입은 다른 곳에 따로 선언하지 않아도 된다. (물론 그렇게 해도 문제가 발생하지는 않는다)

이 함수의 정의는 다음과 같다.

void dumpFoo(const Foo& foo)
{
// private 및 protected 데이터 멤버를 비롯한 foo의 데이터를 모두 콘솔에 출력한다.
}

이 함수를 작성하는 방법은 다른 함수와 같다. Foo의 private와 protected 데이터 멤버에 직접 접근할 수 있다는 점만 다르다. 이렇게 함수를 정의할 때는 friend 키워드를 생략해도 된다.

프렌드로 지정할 클래스, 메서드, 함수는 반드시 접근할 클래스 안에서 지정해야 한다. 이들을 다른 곳에서 대상 클래스의 프렌드라고 선언해서 그 클래스의 private이나 protected 멤버에 접근하게 할 수는 없다.

클래스마 네서드를 프렌드로 지정하는 기능을 너무 많이 사용하면 클래스의 내부가 외부 클래스나 함수에서 드러나서 캡슐화 원칙이 깨진다. 따라서 꼭 필요할 때만 사용한다.

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

Spreadsheet 클래스

8장에서 SpreadsheetCell 클래스를 다루었는데 여기서는 Spreadsheet 클래스를 작성해보겠다. 먼저 Spreadsheet를 SpreadsheetCell 타입의 2차원 배열로 만든다. 그리고 Spreadsheet에서 특정 위치에 있는 셀을 설정하거나 조회하는 메서드를 정의한다. 상용 스프레드시트 애플리케이션은 한 축은 문자, 다른 축은 숫자를 사용하지만 여기서는 모두 숫자로 표시한다. 

Spreadsheet의 첫 버전은 다음과 같다.

#include <cstddef>
#include "SpreadsheetCell.h"

class Spreadsheet
{
public:
Spreadsheet(size_t width, size_t height);
void setCellAt(size_t x, sizt_t y, const SpreadsheetCell& cell);
SpreadsheetCell& getCellAt(size_t x, size_t y);
private:
bool inRange(size_t value, size_t upper) const;
size_t mWidth = 0;
size_t mHeight = 0;
SpreadsheetCell** mCells = nullptr;
}

Note) Spreadsheet 클래스는 mCells 배열에 대해 일반 포인터를 사용한다. 이 장에서는 동적 메모리 할당의 결과와 클래스에서 동적 메모리를 다루는 방법을 설명하기 위해 이렇게 작성했지만 실전에서는 std::vector와 같은 C++표준 컨테이너를 사용하기 바란다. 참고로 최신 C++에서는 절대로 일반 포인터를 사용하면 안 된다.

여기서 Spreadsheet 클래스를 보면 멤버의 타입을 SpreadsheetCell 타입의 표준 2차원 배열이 아니라 SpreadsheetCell** 타입으로 정의했는데, 이렇게 한 이유는 Spreadsheet 객체마다 크기가 다를 수 있기 때문에 이 클래스의 생성자에서 클라이언트가 지정한 높이와 너비에 맞게 2차원 배열을 동적으로 할당해야 하기 때문이다.

2차원 배열을 동적으로 할당하려면 다음과 같이 코드를 작성한다. 이때 C++은 자바와 달리 new SpreadsheetCell[mWidth][mHeight]와 같이 간단히 작성할 수 없다는 점에 주의하라

Spreadsheet::Spreadsheet(size_t width, size_t height) : mWidth(width), mHeight(height)
{
mCells = new SpreadsheetCell*[mWidth];

for (size_t i = 0; i < mWidth; i++)
{
mCells[i] = new SpreadsheetCell[mHeight];
}
}

다음 그림은 너비 4, 높이 3의 크기를 가진 s1이란 이름의 Spreadsheet 객체가 스택에 할당되었을 때의 메모리 상태를 보여준다.

다음은 셀 하나를 읽고 쓰는 메서드를 구현한 코드다.

void Spreadsheet::setCellAt(int x, int y, const SpreadsheetCell& cell)
{
if (!inRange(x, mWidth) || !inRange(y, mHeight))
{
throw std::out_of_range("");
}
mCells[x][y] = cell;
}

SpreadsheetCell& Spreadsheet::getCellAt(int x, int y)
{
if (!inRange(x, mWidth) || !inRange(y, mHeight))
{
throw std::out_of_range("");
}
return mCells[x][y];
}

두 메서드 코드에 x, y가 스프레드 시트에 실제 존재하는 좌표인지 확인하는 작업을 inRange()라는 헬퍼 메서드로 처리했다. 

setCellAt()과 getCellAt() 메서드를 보면 코드가 중복된 것을 알 수 있는데, 다음과 같이 verifyCoordinate() 메서드를 만들어서 사용하자.

// 선언부
void verifyCoordinate(size_t x, size_t y) const;

// 구현부
void Spreadsheet::verifyCoordinate(size_t x, size_t y) const
{
if (x >= mWidth || y >= mHeight)
{
throw std::out_of_range("");
}
}

// get, set은 이렇게 고친다.
void Spreadsheet::setCellAt(int x, int y, const SpreadsheetCell& cell)
{
verifyCoordinate(x, y);
mCells[x][y] = cell;
}

SpreadsheetCell& Spreadsheet::getCellAt(int x, int y)
{
verifyCoordinate(x, y);
return mCells[x][y];
}

소멸자로 메모리 해제하기

동적으로 할당한 메모리를 다 썼다면 반드시 해제해야 한다. 객체 안에서 동적으로 할당한 메모리는 그 객체의 소멸자에서 해제하는 것이 바람직하다. 그러면 컴파일러는 이 객체가 소멸될 때 소멸자를 호출하게 해준다. 이를 위해 Spreadsheet 클래스에 다음과 같이 소멸자를 선언한다.

class Spreadsheet
{
public:
Spreadsheet(size_t width, size_t height);
~Spreadsheet();
// 이하 코드 생략
}

소멸자의 이름은 생성자의 이름과 같고 그 앞에 ~를 붙인다. 소멸자는 인수를 받지 않으며 생성자와 달리 단 하나만 존재한다. 소멸자는 익셉션이 발생하지 않기 때문에 기본적으로 noexcept가 적용된다.

Note) 아무런 익셉션도 던지지 않는 함수 앞에는 noexcept 키워드를 붙인다. void myNonThrowingFunction() noexcept { /* … */ } 소멸자는 기본적으로 noexcept가 적용되므로 키워드를 따로 적지 않아도 된다. noexcept로 지정한 함수에서 익셉션이 발생하면 프로그램이 멈춘다.

Spreadsheet 클래스의 소멸자는 다음과 같이 구현한다.

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

복제와 대입 처리하기

8장에서 설명했듯이 복제 생성자나 대입 연산자를 직접 작성하지 않으면 컴파일러가 자동으로 만들어준다. 이렇게 컴파일러에서 생성된 메서드는 객체의 데이터 멤버에 대해 복제 생성자나 대입 연산자를 재귀적으로 호출한다.

하지만 int, double, 포인터와 같은 기본 타입에 대해서는 비트 단위 복제(bitwise copy), 얕은 복제(shallow copy) 또는 대입이 적용된다. 즉, 원본 객체의 데이터 멤버를 대상 객체로 단순히 복제하거나 대입하기만 한다. 그런데 메모리를 동적으로 할당한 객체를 이렇게 처리하면 문제가 발생한다. 

다음 코드를 보자. 여기 나온 printSpreadsheet() 함수에 스프레드시트 객체 s1을 전달하면 이 함수의 매개변수인 s를 초기화하는 과정에서 s1을 복제한다.

void printSpreadsheet(Spreadsheet s)
{
// 나머지 코드 생략
}

int main()
{
Spreadsheet s1(4, 3);
printSpreadsheet(s1);
return 0;
}

이렇게 전달한 Spreadsheet는 mCells라는 포인터 변수 하나만 갖고 있다. 얕은 복제를 적용하면 대상 객체는 mCells에 담긴 데이터가 아닌 mCells 포인터의 복제본만 받는다. 따라서 아래 그림과 같이 s와 s1이 같은 데이터를 가리키는 포인터가 되는 상황이 발생한다.

이 상태에서 mCells가 가리키는 대상을 s가 변경하면 그 결과가 s1에도 반영된다. 더 심각한 문제는 printSpreadsheet() 함수가 리턴할 때 s의 소멸자가 호출되면서 mCells가 가리키던 메모리를 해제해버린다는 것이다.

이렇게 되면 s1은 더는 올바른 메모리를 가리키지 않게 된다. 이런 포인터를 댕글링 포인터라고 한다.

대입 연산을 수행할 때는 이보다 더 심각한 문제가 발생한다. 예컨대 다음과 같은 코드를 작성했다고 하자.

Spreadsheet s1(2, 2), s2(4, 3);
s1 = s2;

첫 번째 줄만 실행했을 때 두 객체가 생성된 상태는 아래 그림과 같다.

이어 두 번째 줄에 나온 대입 문장을 실행한 결과는 아래 그림과 같다.

이 상태를 보면 s1과 s2에 있는 mCells 포인터가 가리키는 메모리가 똑같을 뿐만 아니라 s2에서 mCells가 가리키던 메모리는 미아(orphan)가 된다. 이런 상황을 메모리 누수라 부른다. 그래서 대입 연산자에서는 반드시 깊은 복제(deep copy)를 적용해야 한다.

이처럼 C++ 컴파일러가 자동으로 생성하는 복제 생성자나 대입 연산자를 그대로 사용하면 위험하다.

Spreadsheet 복제 생성자

Spreadsheet 클래스에 다음과 같이 복제 생성자를 선언한다.

class Spreadsheet
{
public:
Spreadsheet(const Spreadsheet& src);
// 나머지 코드 생략
}

이 복제 생성자를 다음과 같이 정의한다.

Spreadsheet::Spreadsheet(const Spreadsheet& src) : Spreadsheet(src.mWidth, src.mHeight)
{
for (size_t i = 0; i < mWidth; i++)
{
for (size_t j = 0; j < mHeight; j++)
{
mCells[i][j] = src.mCells[i][j];
}
}
}

이 코드는 위임 생성자를 사용했다. 이 복제 생성자의 생성자 이니셜라이저를 보면 적절한 양의 메모리를 할당하는 작업을 비복제 버전의 생성자에 맡긴다. 그러고 나서 실제로 값을 복제하는 작업을 수행한다. 이렇게 하면 동적으로 할당된 2차원 배열인 mCells를 깊은 복제로 처리할 수 있다.

여기서는 기존 mCells를 삭제하는 작업을 할 필요가 없다. 이 코드는 복제 생성자이기 때문에 현재(this) 객체에 아직 기존에 생성된 mCells이 존재하지 않기 때문이다.

Spreadsheet 대입 연산자

이번에는 Spreadsheet 클래스에 대입 연산자를 선언하자.

class Spreadsheet
{
public:
Spreadsheet& operator=(const Spreadsheet& rhs);
// 나머지 코드 생략
}

구현은 다음과 같다.

Spreadsheet& Spreadsheet::operator=(const Spreadsheet& rhs)
{
// 자신을 대입하는지 확인한다. --사실 내 스타일로는 어차피 최종적으로 *this를 리턴하기 때문에 this != &rhs 일 때 대입하는 코드를 넣고 최종적으로 return *this를 하는 식으로 구현했을 것.
if (this == &rhs)
{
return *this;
}

// 기존 메모리를 해제한다.
for (size_t i = 0; i < mWidth; i++)
{
delete[] mCells[i];
}
delete[] mCells;
mCells = nullptr;

// 새로운 메모리를 할당한다.
mWidth = rhs.mWidth;
mHeight = rhs.mHeight;

mCells = new SpreadsheetCell*[mWidth];
for (size_t i = 0; i < mWidth; i++)
{
mCells[i] = new Spreadsheet[mHeight];
}

// 데이터를 복제한다.
for (size_t i = 0; i < mWidth; i++)
{
for (size_t j = 0; j < mHeight; j++)
{
mCells[i][j] = rhs.mCells[i][j];
}
}

return *this;
}

이 코드는 먼저 자기 자신을 대입하는지 검사한 다음 this 객체에 현재 할당된 메모리를 해제한다. 그 후 새로 메모리를 할당하고 마지막으로 개별 원소를 복제한다. 이 메서드는 하는 일이 상당히 많은데 그만큼 문제가 발생할 여지도 많다. 즉 this 객체가 비정상적인 상태가 될 수 있다.

예컨대 메모리를 정상적으로 해제해서 mWidth, mHeight는 제대로 설정됐지만 메모리를 할당하는 루프문에서 익셉션이 발생했다고 하자. 그러면 이 메서드의 나머지 코드를 건너뛰고 리턴해버린다. 이렇게 Spreadsheet 인스턴스가 손상됐기 때문에 여기에 있는 mWidth, mHeight 데이터 멤버는 일정한 크기를 갖는다고 선언했지만 실제로는 mCells 데이터 멤버에 필요한 만큼 메모리를 갖고 있지 않다. 결국 이 코드는 익셉션이 발생하면 문제가 생긴다.

이럴 때는 모두 정상적으로 처리하거나 그렇지 못하면 this 객체를 건드리지 않아야 한다. 예외가 발생해도 문제가 생기지 않게 대입 연산자를 구현하려면 복제 후 맞바꾸기(copy-and-swap) 패턴을 적용하는 것이 좋다.

즉, 비멤버 swap() 함수를 만들고 Spreadsheet 클래스의 프렌드로 지정한다. 이렇게 하지 않고 클래스 멤버로 swap() 메서드를 추가해도 되지만 swap()을 비멤버로 구현하는 것이 더 낫다. 그래야 다양한 알고리즘에서 활용할 수 있기 때문이다. (별도의 static 클래스에서 동작하는 메서드로 만드는게 나은거 아닌가?)

class Spreadsheet
{
  public:
  Spreadsheet& operator=(const Spreadsheet& rhs);
friend void swap(Spreadsheet& first, Spreadsheet& second) noexcept;    
    // 나머지 코드 생략
}

복제 후 맞바꾸기 패턴을 익셉션에 안전하게 하려면 swap() 함수에서 절대로 익셉션을 던지면 안 된다. 따라서 noexcept로 지정한다. 그리고 swap() 함수에서 실제로 데이터 멤버를 교체하는 작업은 표준 라이브러리에서 제공하는 유틸리티 함수인 std::swap()로 처리한다.

void swap(Spreadsheet& first, Spreadsheet& second) noexcept
{
using std::swap;

swap(first.mWidth, second.mWidth);
 swap(first.mHeight, second.mHeight);
swap(first.mCells, second.mCells);
}

이렇게 swap() 함수를 익셉션에 안전하게 만들면 다음과 같이 대입 연산자를 구현하는데 활용할 수 있다.

Spreadsheet& Spreadsheet::operator=(const Spreadsheet& rhs)
{
if (this == &rhs)
{
return *this;
}

Spreadsheet temp(rhs); // 모든 작업을 임시 인스턴스에서 처리한다.
swap(*this, temp); // 익셉션을 발생하지 않는 연산으로만 작업을 처리한다.
return *this;
}

위와 같이 작성하면 익셉션이 발생하더라도 Spreadsheet 객체는 변하지 않는다. 이렇게 구현하는 과정은 다음 세 단계로 구성된다.

  • 1단계는 임시 복제본을 만든다. 이렇게 하면 현재 Spreadsheet 객체의 상태를 변경하지 않는다. 따라서 이 과정에서 익셉션이 발생해도 문제가 되지 않는다.
  • 2단계는 swap() 함수를 이용하여 현재 객체를 생성된 임시 복제본으로 교체한다. swap() 함수에서는 익셉션이 절대로 발생하면 안 된다.
  • 3단계에서는 임시 객체를 제거한다. 그러면 모든 메모리를 정리해서 원본 객체가 남게 된다.

Note) C++은 복제뿐만 아니라 이동 의미론도 지원한다. 이를 사용하려면 이동 생성자와 이동 대입 연산자를 정의해야 한다. 그러면 특정 상황에서 성능을 높일 수 있다.

대입과 값 전달 방식 금지

때로는 클래스에서 메모리를 동적으로 할당할 때 그냥 아무도 이 객체에 복제나 대입을 한 수 없게 만드는게 간편할 때가 있다. 이렇게 하려면 operator= 과 복제 생성자를 명싲거으로 삭제하면 된다. 그러면 이 객체를 값으로 전달하거나 함수나 메서드에서 이 객체를 리턴하거나 이 객체에 뭔가를 대입하려면 컴파일러에서 에러 메시지를 발생시킨다. 이렇게 대입이나 값 전달 방식을 금지하려면 Spreadsheet 클래스를 다음과 같이 정의한다.

class Spreadsheet
{
public:
Spreadsheet(size_t width, size_t height);
Spreadsheet(const Spreadsheet& src) = delete;
~Spreadsheet();
Spreadsheet& operator=(const Spreadsheet& rhs) = delete;
// 나머지 코드 생략
}

delete로 지정한 메서드는 구현할 필요 없다. 컴파일러는 이러한 메서드를 호출하는 것을 허용하지 않기 때문에 링커는 이렇게 지정된 메서드를 전혀 참조하지 않는다.

이동 의미론으로 이동 처리하기

객체에 이동 의미론을 적용하려면 이동 생성자와 이동 대입 연산자를 정의해야 한다. 그러면 컴파일러는 원본 객체를 임시 객체로 만들어서 대입 연산을 수행한 뒤 임시 객체를 제거한다. 이 과정에서 이동 생성자와 이동 대입 연산자를 활용한다.

이동 생성자와 이동 대입 연산자는 원본 객체에 있는 데이터 멤버를 새 객체로 이동 시키기 땜누에 원본 객체는 정상적이지만 미확정된 상태로 남게 된다. 이러한 원본 객체의 데이터 멤버는 대부분 널 값으로 초기화된다. 이렇게 함으로써 메모리를 비롯한 리소스의 소유권을 다른 객체로 이동 시킨다.

이 과정은 멤버 변수에 대한 얕은 복제와 비슷하다. 또한 메모리나 다른 리소스에 대한 소유권을 전환함으로써 댕글링 포인터나 메모리 누수를 방지한다.

이동 의미론을 구현하기 전에 먼저 우측값(rvalue)과 우측값 레퍼런스(rvalue reference)부터 알아둘 필요가 있다.

우측값 레퍼런스

C++에서 좌측값(lvalue)은 변수처럼 이름과 주소를 가진 대상이다. 대입문에서 왼쪽에 나온다고 해서 좌측값이라 부른다. 반면 우측값(rvalue)은 리터럴, 임시 객체, 값처럼 좌측값이 아닌 모든 대상을 가리킨다. 일반적으로 우측값은 대입문의 오른쪽에 나온다.

int a = 4 * 2;

이 문장에서 a는 좌측값이며 이름을 갖고 있으며 &a로 주소를 가져올 수 있다. 반면 4 * 2라는 표현식의 결과는 우측값이다. 우측값은 임시값이라서 이 문장을 실행하고 나면 제거된다. 여기서는 임시 변수에 있는 값의 복사본을 a란 이름의 변수에 저장한다.

우측값 레퍼런스란 개념도 있다. 말 그대로 우측값에 대한 레퍼런스다. 구체적으로 말하면 우측값이 임시 객체일 때 적용되는 개념이다. 우측값 레퍼런스는 임시 객체에 대해 적용할 함수를 컴파일러가 선택하기 위한 용도로 사용한다. 우측값 레퍼런스로 구현하면 크기가 큰 값(객체)을 복사하는 연산이 나오더라도 컴파일러는 이 값이 나중에 삭제될 임시 객체라는 점을 이용하여 그 값에 우측값에 대한 포인터를 복사하는 방식으로 실행할 수 있다.

함수의 매개변수에 &&를 붙여서 우측값 레퍼런스로 만들 수 있다. 일반적으로 임시 객체는 const type&로 취급하지만 함수의 오버로딩 버전 중에서 우측값 레퍼런스를 사용하는 것이 있다면 그 버전으로 임시 객체를 처리한다.

예컨대 다음 코드와 같다. 여기서는 먼저 handleMessage() 함수를 두 버전으로 정의한다. 하나는 좌측값 레퍼런스를 받고 다른 하나는 우측값 레퍼런스를 받는다.

// 좌측값 레퍼런스 매개변수
void handleMessage(std::string& message)
{
cout << "handleMessage with lvalue reference: " << message << endl;
}

// 우측값 레퍼런스 매개변수
void handleMessage(std::string&& message)
{
cout << "handleMessage with rvalue reference: " << message << endl;
}

먼저 handleMessage()를 다음과 같이 이름 있는 변수를 인수로 전달해서 호출해 보자.

std::string a = "Hello ";
std::string b = "World";
handleMessage(a); // handleMessage(string& value)를 호출

전달한 인수가 a라는 이름을 가진 변수이므로 handleMessage() 함수 중에서 좌측값 레퍼런스를 받는 버전이 호출된다. 이 함수 안에서 매개변수로 받은 레퍼런스로 변경한 사항은 a 값에도 똑같이 반영된다.

이번에는 handleMessage() 함수를 다음과 같이 표현식을 인수로 전달해서 호출해 보자.

handleMessage(a + b);  // handleMessage(string& value)를 호출

a + b 표현식으로 임시 변수가 생성되는데, 임시 변수는 좌측값이 아니므로 handleMessage() 함수 중에서 우측값 레퍼런스 버전이 호출된다. 전달한 인수는 임시 변수이기 때문에 함수 안에서 매개변수의 레퍼런스로 변경한 값은 리턴 후에 사라진다.

handleMessage() 함수의 인수로 리터럴을 전달해도 된다. 이때도 우측값 레퍼런스 버전이 호출된다. 리터럴은 좌측값이 될 수 없기 때문이다.

여기서 좌측값 레퍼런스를 받는 handleMessage() 함수를 삭제한 뒤 handleMessage(b) 처럼 이름 있는 변수를 전달해서 호출하면 컴파일 에러가 발생한다. 우측값 레퍼런스 타입의 매개변수(string&& message)를 좌측값(b) 인수에 바인딩할 수 없기 때문이다. 

이때 좌측값을 우측값으로 캐스팅하는 std::move()를 사용하면 컴파일러가 우측값 레퍼런스 버전의 handleMessage()를 호출하게 만들 수 있다.

handleMessage(std::move(b));

다시 한 번 강조하면 이름 있는 변수는 좌측값이다. 따라서 handleMessage() 함수 안에서 우측값 레퍼런스 타입인 message 매개변수 자체는 이름이 있기 때문에 좌측값이다. 이처럼 타입이 우측값 레퍼런스인 매개변수를 다른 함수에 우측값으로 전달하려면 std::move()를 이용하여 좌측값을 우측값으로 캐스팅해야 한다. 앞서 본 코드에 다음과 같이 우측값 레퍼런스 매개변수를 받는 함수를 추가해 보자.

void helper(std::string&& message)
{
}

이 함수를 다음과 같이 호출하면 컴파일 에러가 발생한다.

void handleMessage(std::string&& message)
{
helper(message);
}

helper() 함수는 우측값 레퍼런스를 받는 반면 handleMessage()가 전달하는 message는 좌측값이기 때문에 컴파일 에러가 발생한다. 이를 해결하려면 다음과 같이 std::move()로 좌측값을 우측값으로 캐스팅해서 전달해야 한다.

void handleMessage(std::string&& message)
{
helper(std::move(message));
}

함수의 매개변수 말고도 우측값 레퍼런스를 사용하는 경우가 있다. 변수를 우측값 레퍼런스 타입으로 선언해서 값을 할당할 수도 있다. 물론 이런 사례는 흔치 않다. 예컨대 C++에서는 다음과 같이 작성하는 것을 허용하지 않는다.

int& i = 2;  // 에러: 상수에 대한 레퍼런스
int a = 2, b = 3;
int& j = a + b; // 에러: 임시 객체에 대한 레퍼런스

이때는 다음과 같이 우측값 레퍼런스를 사용한다.

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

이동 의미론 구현 방법

이동 의미론은 우측값 레퍼런스로 구현한다. 클래스에 이동 의미론을 추가하려면 이동 생성자와 이동 대입 연산자를 구현해야 한다. 이때 이동 생성자와 이동 대입 연산자를 noexcept로 지정해서 두 메서드에서 익셉션이 절대 발생하지 않는다고 컴파일러에 알려줘야 한다. 특히 표준 라이브러리와 호환성을 유지하려면 반드시 이렇게 해야 한다.

예컨대 표준 라이브러리 컨테이너와 완벽하게 호환되도록 구현할 때 이동 의미론을 적용했다면 저장된 객체를 이동시키기만 한다. 또한 이 과정에서 익셉션도 던지지 않는다. 다음과 같이 Spreadsheet 클래스에 이동 생성자와 이동 대입 연산자를 추가한 코드를 살펴보자. 여기에 cleanup()과 moveFrom() 이란 헬퍼 메서드도 추가했다. cleanup() 은 소멸자와 이동 대입 연산자에서 사용하고, moveFrom()은 원본 객체의 멤버 변수를 대상 객체로 이동시킨 뒤 원본 객체를 리셋한다.

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 < mWidth; i++)
{
delete[] mCells[i];
}
delete[] mCells;
mCells = nullptr;
mWidth = mHeight = 0;
}

void Spreadsheet::moveFrom(Spreadsheet& src) noexcept
{
// 데이터에 대한 얕은 복제
mWidth = src.mWidth;
mHeight = src.mHeight;
mCells = src.mCells;

// 소유권이 이전 됐기 때문에 소스 객체를 리셋한다.
src.mWidth = 0;
src.mHeight = 0;
src.mCells = nullptr;
}

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

// 이동 대입 연산자
Spreadsheet& Spreadsheet::operator=(Spreadsheet&& rhs) noexcept
{
// 자기 자신을 대입하는지 확인한다.
if (this == &rhs)
{
return *this;
}

// 예전 메모리를 해제한다.
cleanup();

moveFrom(rhs);

return *this;
}

이동 생성자와 이동 대입 연산자는 모두 mCells에 대한 메모리 소유권을 원본 객체에서 새로운 객체로 이동 시킨다. 그리고 원본 객체의 소멸자가 이 메모리를 해제하지 않도록 원본 객체의 mCells 포인터를 널 포인터로 리셋한다. 이 시점에서는 이미 메모리에 대한 소유권이 새 객체로 이동했기 때문이다.

당연한 말이지만 이동 의미론은 원본 객체를 삭제할 때만 유용하다. 이동 생성자와 이동 대입 연산자도 일반 생성자와 복제 대입 연산자와 마찬가지로 명시적으로 삭제하거나 디폴트로 만들 수 있다.

사용자가 클래스에 복제 생성자, 복제 대입 연산자, 이동 대입 연산자, 소멸자를 직접 선언하지 않았다면 컴파일러가 디폴트 이동 생성자를 만들어준다. 또한 사용자가 클래스에 복제 생성자, 이동 생성자, 복제 대입 연산자, 소멸자를 직접 선언하지 않았다면 컴파일러는 디폴트 이동 대입 연산자를 만들어준다.

Note) 클래스에 동적 할당 메모리를 사용하는 코드를 작성했다면 소멸자, 복제 생성자, 이동 생성자, 복제 대입 연산자, 이동 대입 연산자를 반드시 구현한다. 이를 5의 규칙(Rule of Five)라 부른다.

객체 데이터 멤버 이동하기

moveFrom() 메서드는 데이터 멤버 세 개를 직접 대입한다. 세 값이 기본 타입이기 때문이다. 반면 데이터 멤버가 객체라면 std::move()로 이들 객체를 이동시켜야 한다. Spreadsheet 클래스에 mName이란 이름의 std::string 타입 데이터 멤버가 있다고 하자. 그러면 moveFrom() 메서드를 다음과 같이 구현한다.

void Spreadsheet::moveFrom(Spreadsheet& src) noexcept
{
// 객체 데이터 멤버를 이동시킨다.
mName = std::move(src.mName);

// 이동 대상
// 데이터에 대한 얕은 복제
mWidth = src.mWidth;
mHeight = src.mHeight;
mCells = src.mCells;

// 소유권이 이전됐으므로 원본 객체를 초기화 한다.
src.mWidth = 0;
src.mHeight = 0;
src.mCells = nullptr;
}
swap() 함수로 구현한 이동 생성자와 이동 대입 연산자

앞서 이동 생성자와 이동 대입 연산자를 구현할 때 데이터 멤버를 모두 얕은 복제로 이동시키는 작업을 moveFrom() 헬퍼 메서드로 처리했다. 이렇게 구현하면 Spreadsheet 클래스에 데이터 멤버를 새로 추가할 때 swap() 함수와 moveFrom() 메서드를 동시에 수정해야 한다. 만약 둘 중 어느 하나라도 깜박 잊고 수정하지 않으면 버그가 발생한다. 이런 버그가 발생하지 않게 하려면 이동 생성자와 이동 대입 연산자를 디폴트 생성자와 swap() 함수로 구현한다.

가장 먼저 Spreadsheet 클래스에 디폴트 생성자부터 추가한다. 이 클래스의 사용자가 디폴트 생성자를 직접 사용할 일은 없기 때문에 private로 지정한다.

class Spreadsheet
{
private:
Spreadsheet() = default;
// 나머지 코드 생략
}

다음으로 cleanup()과 moveFrom() 헬퍼 메서드를 삭제한다. cleanup() 메서드에 있던 코드를 소멸자로 옮긴다. 그러면 이동 생성자와 이동 대입 연산자를 다음과 같이 작성할 수 있다.

Spreadsheet::Spreadsheet(Spreadsheet&& src) noexcept : Spreadsheet()
{
swap(*this, src);
}

Spreadsheet& Spreadsheet::operator=(Spreadsheet&& rhs) noexcept
{
Spreadsheet temp(std::move(rhs));
swap(*this, temp);
return *this;
}

이동 생성자는 먼저 디폴트 생성자에 작업을 위임한다. 그런 다음 디폴트 생성자가 만든 *this를 원본 객체와 맞바꾼다. 이동 대입 연산자는 먼저 rhs로 이동 생성해서 Spreadsheet에 대한 로컬 인스턴스를 만든다. 그러고 나서 이동 생성된 로컬 Spreadsheet 인스턴스를 *this와 맞바꾼다.

이동 생성자와 이동 대입 연산자를 디폴트 생성자와 swap() 함수로 구현하면 앞서 moveFrom()으로 구현했던 것보다 효율성이 떨어질 수 있다. 하지만 코드가 줄고, 클래스에 데이터 멤버를 새로 추가할 때 swap()만 수정하면 되기 때문에 버그 발생 확률을 낮출 수 있다는 장점이 있다.

Spreadsheet의 이동 연산자 테스트

앞서 작성한 Spreadsheet의 이동 생성자와 이동 대입 연산자를 다음과 같이 테스트해 보자

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

int main()
{
vector<Spreadsheet> vec;
for (int i = 0; i < 2; ++i)
{
cout << Iteration " << i << endl;
vec.push_back(Spreadsheet(100, 100));
cout << endl;
}

Spreadsheet s(2, 3);
s = createObject();

Spreadsheet s2(5, 6);
s2 = s;
return 0;
}

vector는 객체를 추가할 때마다 동적으로 커진다. 이렇게 할 수 있는 이유는 필요에 따라 더 큰 덩어리의 메모리를 할당해서 기존 vector에 있던 객체를 복제하거나 이동하기 때문이다. 이때 이동 생성자가 정의돼 있으면 컴파일러는 해당 객체를 복제하지 않고 이동시킨다. 이처럼 이동 방식으로 옮기기 때문에 깊은 복제를 수행할 필요가 없어서 훨씬 효율적이다.

moveFrom() 메서드로 구현한 Spreadsheet의 모든 생성자와 대입 연산자에 출력 문장을 추가하면 다음과 같은 결과를 볼 수 있다. (C++ 표준은 vector의 초기 용량이나 확장 방식을 따로 지정하지 않았기 때문에 사용하는 컴파일러의 종류에 따라 출력 결과가 달라질 수 있다.)

Iteration 0
Normal constructor (1)
Move constructor (2)
Iteration 1
Normal constructor (3)
Move constructor (4)
Move constructor (5)
Normal constructor (6)
Normal constructor (7)
Move assignment operator (8)
Normal constructor (9)
Copy assignment operator (10)
Normal constructor (11)
Copy constructor (12)

반복문을 처음 실행할 때는 vector가 비어 있다. 반복문에 나온 다음 코드를 살펴보자.

vec.push_back(Spreadsheet(100, 100));

이 문장에서 일반 생성자 (1)을 호출하여 Spreadsheet 객체를 새로 생성한다. 그리고 새로 들어온 객체를 담을 수 있도록 이 vector의 공간을 적절히 조정한다. 그런 다음 이동 생성자 (2)를 호출해서 방금 생성된 Spreadsheet 객체를 vector로 이동시킨다.

반복문을 두 번째 실행할 때 Spreadsheet 객체(두 번째 객체)가 다시 생성되면서 일반 생성자 (3)을 호출한다. 이 시점에서 vector는 원소를 하나만 가지고 있다. 따라서 두 번째 객체를 담을 수 있도록 공간을 다시 조정한다. vector의 크기가 변했기 때문에 이전에 추가한 원소를 새로 크기를 조정한 vector로 이동시켜야 한다. 그래서 이전에 추가한 원소마다 이동 생성자가 호출된다. 현재는 vector에 원소가 하나 뿐이어서 이동 생성자 (4)도 한 번만 호출된다. 마지막으로 새로 생성한 Spreadsheet 객체를 이 객체의 이동 생성자 (5)를 통해 vector로 이동시킨다.

다음에는 Spreadsheet 객체 s를 일반 생성자 (6)을 사용해서 생성한다. createObject() 함수는 임시 Spreadsheet 객체를 일반 생성자 (7)로 생성해서 리턴하며 그 결과를 변수 s에 대입한다. 이렇게 대입한 뒤에는 createObject() 함수로 생성한 임시 객체가 사라지기 때문에 컴파일러는 일반 복제 대입 연산자가 아닌 이동 대입 연산자 (8)를 호출한다. 

이어서 s2라는 이름으로 Spreadsheet 객체를 하나 더 만든다. 이번에도 일반 생성자 (9)가 호출된다. s2=s라는 대입문으로부터 복제 대입 연산자 (10)이 호출된다. 우변의 객체는 임시 객체가 아닌 이름 있는 객체이기 때문이다. 

이 복제 대입 연산자는 임시 복제본을 생성하는데, 여기서 복제 생성자를 호출한다. 호출된 복제 생성자는 먼저 일반 생성자를 호출하고 나서 복제 작업을 수행한다. (11, 12)

Spreadsheet 클래스에 이동 의미론을 구현하지 않으면 이동 생성자와 이동 대입 연산자를 호출하는 부분은 모두 복제 생성자와 복제 대입 연산자로 대체된다. 앞의 예제에서 반복문에 있는 Spreadsheet 객체에 담긴 원소는 10,000(100 x 100)개다. 

Spreadsheet의 이동 생성자와 이동 대입 연산자를 구현할 때는 메모리를 할당할 필요가 없지만, 복제 생성자와 복제 대입 연산자를 구현할 때는 각각 101개를 할당한다. 이처럼 이동 의미론을 적용하면 특정한 상황에서 성능을 크게 높일 수 있다.

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

이동 의미론으로 성능을 높이는 또 다른 예제로 두 객체를 스왑하는 swap() 함수를 살펴보자. 다음의 swapCopy() 함수는 이동 의미론을 적용하지 않았다.

void swapCopy(T& a, T& b)
{
T temp(a);
a = b;
b = temp;
}

먼저 a를 temp에 복제한 뒤, b를 a에 복제하고, 마지막으로 temp를 b에 복제한다. 그런데 만약 T가 복제하기에 상당히 무거우면 성능이 크게 떨어진다. 이럴 때는 다음과 같이 이동 의미론을 적용해서 복제가 발생하지 않도록 구현한다.

void swapCopy(T& a, T& b)
{
T temp(std::move(a));
a = std::move(b);
b = std::move(temp);
}

표준 라이브러리의 std::swap()이 바로 이렇게 구현됐다.

영의 규칙

앞에서 5의 규칙을 설명ㅎ했다. 지금까지 설명한 내용은 모두 이러한 다섯 가지 특수 멤버 함수(소멸자, 복제 생성자, 이동 생성자, 복제 대입 연산자, 이동 대입 연산자)를 구현하는 방법에 대한 것이다. 여기에 최신 C++은 일명 영의 규칙(0의 규칙)이란 것도 추가했다.

영의 규칙이란 앞서 언급한 다섯 가지 특수 멤버 함수를 구현할 필요가 없도록 클래스를 디자인해야 한다는 것이다. 이렇게 하려면 먼저 예전 방식대로 메모리를 동적으로 할당하지 말고 표준 라이브러리 컨테이너와 같은 최신 구문을 활용해야 한다. 

예컨대 Spreadsheet 클래스에서 SpreadsheetCell** 이란 데이터 멤버 대신 vector<vecotr<SpreadsheetCell>> 을 사용한다. 이 벡터는 메모리를 자동으로 관리하기 때문에 앞서 언급한 다섯 가지 특수 멤버 함수가 필요 없다.

메서드의 종류

static 메서드

메서드도 데이터 멤버처럼 특정 객체 단위가 아닌 클래스 단위로 적요오디는 것이 있다. 이를 static(정적, 스태틱) 메서드라 부르며 데이터 멤버를 정의하는 단계에 함께 작성한다.

(이하 설명 생략)

const 메서드

const 객체란 값이 바뀌지 않는 객체를 말한다. const 객체나 이에 대한 레퍼런스 또는 포인터를 사용할 때는 그 객체의 데이터 멤버를 절대로 변경하지 않는 메서드만 호출할 수 있다. 그렇지 않으면 컴파일 에러가 발생한다.

이처럼 어떤 메서드가 데이터 멤버를 변경하지 않는다고 보장하고 싶을 때 const 키워드를 붙인다. 예컨대 데이터 멤버를 변경하지 않는 메서드를 SpreadsheetCell 클래스에 추가하려면 다음과 같이 메서드를 const로 선언한다.

class SpreadsheetCell
{
public:
double getValue() const;
std::string getString() const;
// 나머지 코드 생략
}

const는 메서드 프로토타입의 일부분이기 때문에 다음과 같이 메서드를 구현하는 코드에서도 반드시 적어야 한다.

double SpreadsheetCell:getValue() const
{
return mValue;
}

std::string SpreadsheetCell::getString() const
{
return doubleToString(mValue);
}

(이하 설명 생략)

mutable 데이터 멤버

때로는 의미상으로 const인 메서드에서 객체의 데이터 멤버를 변경하는 경우가 있다. 이렇게 해도 사용자 데이터에 아무런 영향을 미치지 않지만 엄연히 수정하는 것이기 때문에 이런 메서드를 const로 선언하면 컴파일 에러가 발생할 수 있다.

예컨대 스프레드시트 애플리케이션을 프로파일링해서 여기에 담긴 데이터를 얼마나 자주 읽는지 확인한다고 하자. 가장 간단한 방법은 SpreadsheetCell 클래스에 카운터를 두고 getValue()나 getString()이 호출될 때마다 카운터를 업데이트하는 식으로 호출 횟수를 기록하는 것이다. 

하지만 이런식으로 하면 컴파일러 입장에서 볼 때 non-const 메서드가 되어버리기 때문에 의도에 맞지 않는 방법이다. 이럴 때 횟수를 세는 카운터 변수를 mutable로 선언해서 컴파일러에 이 변수를 const 메서드에서 변경할 수 있다고 알려주면 된다. 이렇게 SpreadsheetCell 클래스를 수정하면 다음과 같다.

class SpreadsheetCell
{
private:
double mValue = 0;
mutable size_t mNumAccesses = 0;
}

getValue()와 getString()은 다음과 같이 정의한다.

double SpreadsheetCell::getValue() const
{
mNumAccesses++;
return mValue;
}

std::string SpreadsheetCell::getString() const
{
mNumAccesses++;
return doubleToString(mValue);
}

메서드 오버로딩

메서드나 함수는 매개변수의 타입이나 개수만 다르게 지정해서 이름이 같은 함수나 메서드를 여러 개 정의할 수 있는데 이를 오버로딩이라 부른다.

(이하 설명 생략)

컴파일러가 set()을 호출하는 코드를 발견하면 매개변수 정보를 보고 어느 버전의 set()을 호출할지 결정한다. 매개변수가 string_view 타입이면 string 버전의 set()을 호출하고, double 타입이면 double 버전의 set()을 호출한다. 이를 오버로딩 결정(overload resolution)이라 한다.

getValue()와 getString()도 get()으로 통일하고 싶을 수 있지만, 여기서는 오버로딩을 적용할 수 없다. C++는 메서드의 리턴 타입에 대한 오버로딩은 지원하지 않는데, 호출할 메서드의 버전을 정확히 결정할 수 없기 때문이다.

const 기반 오버로딩

const를 기준으로 오버로딩할 수도 있다. 예컨대 메서드를 두 개 정의할 때 이름과 매개변수는 같지만 하나는 const로 선언하고, const 객체에서 이 메서드를 호출하면 const 메서드가 실행되고, non-const 객체에서 호출하면 non-const 메서드가 실행된다.

간혹 const 버전과 non-const 버전의 구현 코드가 똑같을 때가 있다. 이러한 코드 중복을 피하려면 스콧 마이어가 제시한 const_cast() 패턴을 적용한다. 예컨대 Spreadsheet 클래스에 non-const SpreadsheetCell 레퍼런스를 리턴하는 getCellAt() 메서드가 있을 때 먼저 다음과 같이 SpreadsheetCell 레퍼런스를 const로 리턴하는 const 버전의 getCellAt() 메서드를 오버로딩 한다.

class Spreadsheet
{
  public:
    SpreadsheetCell& getCellAt(size_t x, size_t y);
  const SpreadsheetCell& getCellAt(size_t x, size_t y) const;
// 코드 생략  
}

그러고 나서 메서드를 정의할 때는 스콧 마이어의 const_cast() 패턴에 따라 const 버전은 예전대로 구현하고, non-const 버전은 const 버전을 적절히 캐스팅해서 호출하는 방식으로 구현한다. 구체적으로 설명하면 std::as_const()로 *this를 const Spreadsheet&로 캐스팅하고, const 버전의 getCellAt()을 호출한 다음 const_cast()를 적용해 리턴된 결과에서 const를 제거하는 방식으로 처리한다.

const SpreadsheetCell& Spreadsheet::getCellAt(size_t x, size_t y) const
{
verifyCoordinate(x, y);
return mCells[x][y];
}

SpreadsheetCell& Spreadsheet::getCellAt(size_t x, size_t y)
{
return const_cast<SpreadsheetCell&>(std::as_const(*this).getCellAt(x, y));
}

std::as_const() 함수는 C++ 17부터 추가됐으므로 현재 자신이 사용하는 컴파일러에서 이를 지원하지 않는다면 다음과 같이 static_cast()를 사용하라

return const_cast<SpreadsheetCell&>(static_cast<const Spreadsheet&>(*this).getCellAt(x, y));

이렇게 getCellAt() 메서드를 두 가지 버전으로 오버로딩하면 const로 지정한 Spreadsheet 객체와 non-const로 지정한 Spreadsheet 객체사 getCellAt() 메서드를 호출하는 방식을 통일시킬 수 있다.

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

오버로딩된 메서드를 명시적으로 삭제할 수 있다. 그러면 특정한 인수에 대해서는 메서드를 호출하지 못하게 된다.

class MyClass
{
public:
void foo(int i);
}

이렇게 정의한 foo() 메서드를 다음과 같이 호출할 수 있다.

MyClass c;
c.foo(123);
c.foo(1.23);

컴파일러는 세 번째 줄을 처리할 때 double 값 1.23을 정수 1로 변환해서 foo를 호출한다. 이렇게 컴파일러가 마음대로 바꿔버리지 않게 하려면 다음과 같이 foo() 메서드의 double 버전을 명시적으로 삭제하도록 선언한다.

class MyClass
{
public:
void foo(int i);
void foo(double d) = delete;
}

인라인 메서드

C++는 메서드(또는 함수)를 별도의 코드 블록에서 구현해서 호출하지 않고 메서드를 호출하는 부분에서 곧바로 구현(정의) 코드를 작성하는 방법도 제공한다. 이를 인라이닝(inlining)이라 부르며, 이렇게 구현한 메서드를 인라인(inline) 메서드라 부른다. 일반적으로 #define 매크로보다 안리안 메서드를 사용하는 것이 더 안전하다.

인라인 메서드를 정의하려면 메서드 정의(구현) 코드에서 이름 앞에 inline 키워드를 지정한다. 예컨대 SpreadsheetCell 클래스의 접근자(accessor)를 인라인 메서드로 만들고 싶으면 다음과 같이 정의한다.

inline double SpreadsheetCell::getValue() const
{
mNumAccesses++;
return mValue;
}

inline std::string SpreadsheetCell::getString() const
{
mNumAccesses++;
return doubleToString(mValue);
}

그러면 컴파일러는 getValue()와 getString()을 호출하는 부분을 함수 호출로 처리하지 않고 그 함수의 본문을 곧바로 집어넣는다. 여기서 주의할 점은 컴파일러는 inline이란 키워드를 단지 참고만 한다는 것이다. 성능에 문제가 될 것 같으면 그냥 무시할 수도 있다.

한 가지 제약사항이 있다. 인라인 메서드(또는 함수)를 호출하는 코드에서 이를 정의하는 코드에 접근할 수 있어야 한다. 당연한 말이지만 컴파일러가 메서드 정의 코드를 볼 수 있어야 메서드 호출 부분을 본문에 나온 코드로 대체할 수 있기 때문이다. 따라서 인라인 메서드는 반드시 프로토타입과 구현 코드를 헤더 파일에 작성한다.

C++은 inline 키워드를 사용하지 않고 클래스 정의에서 곧바로 메서드 정의 코드를 작성하면 인라인 메서드로 처리해 준다. 다음은 SpreadsheetCell 클래스를 이렇게 정의한 예이다.

class SpreadsheetCell
{
public:
double getValue() const { mNumAccesses++; return mValue; }
std::string getString() const
{
mNumAccesses++;
return doubleToString(mValue);
}
}

메서드를 inline으로 선언할 때 발생하는 파급 효과를 제대로 이해하지 않은 채 무작정 안리안 메서드로 구현하는 프로그래머가 많다. 컴파일러는 메서드나 함수에 선언한 inline 키워드를 단지 참고만 할 뿐이다. 실제로는 간단한 메서드나 함수만 인라인으로 처리한다. 컴파일러가 볼 때 인라인으로 처리하면 안 될 메서드를 inline으로 선언하면 그냥 무시한다. 최신 컴파일러는 코드 블롯(code bloat, 코드 비대화)과 같은 몇 가지 기준에 따라 메서드나 함수를 인라인으로 처리할지 판단해서 큰 효과가 없다면 인라인으로 처리하지 않는다.

디폴트 인수

메서드 오버로딩과 비슷한 기능으로 디폴트 인수(default argument)라는 것도 있다. 이 기능을 이용하면 함수나 메서드의 프로토타입에 매개변수의 기본값을 지정할 수 있다. 사용자가 다른 값으로 지정한 인수를 전달하면 디폴트 값을 무시한다. 반면 사용자가 인수를 지정하지 않으면 디폴트값을 적용한다.

여기에 한 가지 제약사항이 있는데 매개변수에 디폴트값을 지정할 때는 반드시 오른쪽 끝의 매개변수부터 싲가해서 중간에 건너뛰지 않고 연속적으로 나열해야 한다. 그렇지 않으면 컴파일러는 중간에 빠진 인수에 디폴트값을 매칭할 수 없다.

class Spreadsheet
{
public:
Spreadhsheet(size_t width = 100, size_t height = 100);
}

(이하 설명 생략)

데이터 멤버의 종류

static 데이터 멤버

클래스의 모든 객체마다 똑같은 변수를 가지는 것은 비효율적이거나 바람직하지 않을 수 있다. 데이터 멤버의 성격이 객체보다 클래스에 가깝다면 객체마다 그 멤버의 복사본을 가지지 않는 것이 좋다.

예컨대 스프레드시트마다 숫자로된 ID를 부여한다고 하자. 그러면 객체를 새로 생성할 때마다 0번부더 차례로 ID 값을 할당하게 된다. 여기서 스프레드시트 수를 저장하는 카운터는 사실 Spreadsheet 클래스에 속해야 한다. 이 값을 Spreadsheet 객체마다 가지면 각 객체마다 저장된 값을 동기화해야 하기 때문이다.

C++에서 제공하는 static 데이터 멤버를 이용하면 이 문제를 해결할 수 있다. static 데이터 멤버는 객체가 아닌 클래스에 속한다. 이는 전역 변수와 비슷하지만 잣니이 속한 클래스 범위를 벗어날 수 없다.

static 클래스 멤버를 정의하면 소스 파일에서 이 멤버에 대한 공간을 할당해야 하는데, 이 작업은 주로 해당 클래스의 메서드를 정의하는 소스 파일에서 처리한다. 선언과 동시에 초기화해도 되지만 일반 변수나 데이터 멤버와 달리 기본적으로 0으로 초기화 된다. static 포인터는 nullptr로 초기화된다.

(이하 설명 생략)

인라인 변수

C++ 17부터 static 데이터 멤버를 inline으로 선언할 수 있다. 그러면 소스 파일에 공간을 따로 할당하지 않아도 된다. 예컨대 다음과 같다.

class Spreadsheet
{
private:
static inline size_t sCounter = 0;
}

여기서 inline이란 키워드에 주목한다 클래스에서 이렇게 선언하면 소스 파일에 다음과 같이 적지 않아도 된다.

size_t Spreadsheet::sCounter;

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

클래스 메서드 안에서는 static 데이터 멤버를 마치 일반 데이터 멤버인 것처럼 사용한다.

(이하 설명 생략)

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

static 데이터 멤버에 대해서도 접근 제한자/지정자를 적용할 수 있다.

(이하 설명 생략)

const static 데이터 멤버

클래스에 정의된 데이터 멤버를 const로 선언하면 멤버가 생성되고 초기화된 후에는 변경할 수 없게 만들 수 있다. 

(이하 설명 생략)

레퍼런스 데이터 멤버

스프레드시트 애플리케이션 전체를 제어하기 위한 기능을 위해 SpreadsheetApplication 클래스를 작성하자. 한 애플리케이션에서 여러 스프레드시트를 관리하기 때문에 스프레드시트와 통신할 수 있어야 한다. 마찬가지로 스프레드시트마다 애플리케이션 객체에 대한 레퍼런스를 저장할 수도 있다. 그러려면 Spreadsheet 클래스가 SpreadsheetApplication 클래스에 대해 알고 있어야 하고 그 반대도 마찬가지다.

그런데 이렇게 하면 순환 참조가 발생해서 #include 문만으로는 해결할 수 없다. 이럴 때는 헤더 파일 중 어느 한 곳에서 포워드 선언(forward declaration, 전방 선언)을 하면 된다. SpreadsheetApplication에 대해 알 수 있도록 포워드 선언을 적용해서 Spreadsheet 클래스를 다시 정의하면 다음과 같다. 포워드 선언을 하면 컴파일과 링크 속도를 높일 수 있다. (이에 대해선 11장에서 자세히 설명하겠다)

class SpreadsheetApplication;  // 포워드 선언

class Spreadsheet
{
public:
Spreadsheet(size_t width, size_t height, SpreadsheetApplication& theApp);

priavte:
SpreadsheetApplication& mTheApp;
}

SpreadsheetApplication 레퍼런스를 데이터 멤버로 추가했다. 이때 포인터보다 레퍼런스를 사용하는 것이 바람직한데, Spreadsheet는 항상 SpreadsheetApplication을 참조하기 때문이다. 포인터를 사용하면 이런 고나계를 보장할 수 없다.

여기서 애플리케이션에 대한 레퍼런스를 저장한 이유는 레퍼런스를 데이터 멤버로 사용할 수 있다는 것을 보여주기 위해서다. 사실 Spreadsheet와 SpreadsheetApplication 클래스를 이렇게 묶기 보다는 MVC 패러다임에 따라 구성하는 것이 바람직하다.

이렇게 선언한 애플리케이션 레퍼런스는 Spreadsheet 생성자로 전달된다. 레퍼런스는 실제로 가리키는 대상 없이는 존재할 수 없다. 따라서 생성자 이니셜라이저에서 mTheApp의 값을 반드시 지정해야 한다.

Spreadsheet::Spreadsheet(size_t width, size_t height, SpreadsheetApplication& theApp) 
: mID(sCounter++), mWidth(std::min(width, kMaxWidth)), mHeight(std::min(height, kMaxHeight)), mTheApp(theApp)
{
// 코드 생략
}

이 레퍼런스 멤버를 반드시 복제 생성자에서도 초기화해야 한다. 이 ㅈ가업은 자동으로 처리된다. Spreadsheet 복제 생성자는 이 레퍼런스 멤버를 초기화하는 작업을 다른 비복제 생성자에 위임하기 때문이다.

여기서 명심할 점은 레퍼런스를 초기화한 뒤에는 그 레퍼런스가 가리키는 객체를 변경할 수 없다는 것이다. 대입 연산자로 레퍼런스에 값을 대입할 수 없다. 때로는 현재 클래스에서 레퍼런스 데이터 멤버에 대해 대입 연산자를 제공할 수 없을 수도 있다. 이때는 대입 연산자가 deleted로 지정된다.

const 레퍼런스 데이터 멤버

일반 레퍼런스와 마찬가지로 레퍼런스 멤버도 const 객체를 가리킬 수 있다. 예컨대 Spreadsheet가 애플리케이션 객체에 대해 const 레퍼런스만 가지도록 해보자. 다음과 같이 mTheApp을 const 레퍼런스로 선언하도록 클래스 정의를 간단히 수정하면 된다.

class Spreadsheet
{
public:
Spreadsheet(size_t width, size_t height, const SpreadsheetApplication& theApp);

priavte:
const SpreadsheetApplication& mTheApp;
}

const 레퍼런스를 이용해서 정의할 때와 non-const 레퍼런스로 정의할 때 차이는 매우 크다. const 레퍼런스 SpreadsheetApplication 데이터 멤버는 SpreadsheetApplication 객체의 const 메서드만 호출할 수 있다. const 레퍼런스에서 non-const 메서드를 호출하면 컴파일 에러가 발생한다.

레퍼런스 멤버를 static이나 static const로 지정할 수도 있는데, 이렇게 사용할 일은 거의 없다.

중첩 클래스

클래스 정의에 데이터 멤버와 메서드 뿐만 아니라 중첩 클래스와 구조체, 타입 앨리어스(typedef), 열거 타입(enum)도 선언할 수 있다. 이들에 대한 스코프는 모두 그 클래스로 제한된다. public 으로 선언한 멤버를 클래스 외부에서 접근할 때는 ClassName::과 같은 스코프 지정 연산자를 붙여야 한다.

또한 클래스 안에서 다른 클래스를 정의할 수도 있다.

class Spreadsheet
{
public:
class Cell
{
public:
Cell() = default;
Cell(double initialValue);
};
Spreadsheet(size_t width, size_t height, const SpreadsheetApplication& theApp);
}

이렇게 Spreadsheet 클래스 안에 Cell 클래스를 정의하더라도 Cell을 얼마든지 Spreadsheet 클래스 밖에서 참조할 수 있다. 물론 그 앞에 Spreadsheet::라고 스코프를 지정해야 한다. 이 규칙은 메서드 정의 규칙에도 똑같이 적용 된다. 예컨대 Cell의 생성자 중에서 double 타입 인수를 받는 버전을 다음과 같이 정의할 수 있다.

Spreadsheet::Cell::Cell(double initialValue) : mValue(initialValue)
{
}

이렇게 스코프 지정 연산자를 붙이는 규칙은 Spreadsheet 클래스 안에 있는 메서드의 리턴 타입에도 적용된다. 단 매개변수에는 적용되지 않는다.

Spreadsheet::Cell& Spreadsheet::getCellAt(size_t x, size_t y)
{
verifyCoordinate(x, y);
return mCells[x][y];
}

중첩 클래스로 선언한 Cell의 구체적인 정의 코드를 Spreadsheet 클래스 안에 직접 작성하면 Spreadsheet 클래스 저으이 코드가 너무 길어지므로 다음과 같이 선언만 하고 구체적인 정의는 따로 작성하는 것이 좋다.

class Spreadsheet
{
public:
class Cell;
Spreadsheet(size_t width, size_t height, const SpreadsheetApplication& theApp);
}

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

클래스 안에서 상수를 여러 개 정의할 때는 각각을 #define 문으로 작성하지 말고 열거 타입을 사용하는 것이 좋다. 예컨대 SpreadsheetCell 클래스에 셀 컬러를 지정하는 기능을 추가하려면 다음과 같이 선언한다.

class Spreadsheet
{
public:
enum class Color { Red = 1, Green, Blue, Yellow };
void setColor(Color color);
Color getColor() const;
private:
Color mColor = Color::Red;
}

(이하 설명 생략)

연산자 오버로딩

예제: SpreadsheetCell에 대한 덧셈 구현

첫 번째 버전: add 메서드

add() 메서드를 선언해서 두 셀을 더해서 그 결과를 새로 생성한 제 3의 셀에 담아서 리턴하는 내용

(설명 생략)

두 번째 버전: operator+ 오버로딩으로 구현하기

클래스에서 operator를 선언해서 덧셈 연산을 구현

class SpreadsheetCell
{
public:
SpreadsheetCell operator+(const SpreadsheetCell& cell) const;
}

Note) operator+에서 operator와 + 사이에 공백을 넣어도 무방하다.

SpreadsheetCell SpreadsheetCell::operator+(const SpreadsheetCell& cell) const
{
return SpreadsheetCell(getValue() + cell.getValue());
}

C++ 컴파일러가 프로그램을 파싱할 때 +, -, =, << 와 같은 연산자를 발견하면 여기 나온 것과 매개변수가 일치하는 operator+, operator-, operator=, operator<<라는 이름의 함수나 메서드가 있는지 확인한다.

예컨대 컴파일러가 다음과 같이 작성된 문장을 발견하면 다른 SpreadsheetCell 객체를 인수로 받는 operator+란 메서드가 있는지 아니면 SpreadsheetCell 객체 두 개를 인수로 받는 operator+란 이름의 전역 함수가 있는지 찾는다.

SpreadsheetCell aThirdCell = myCell + anotherCell;

SpreadsheetCell 클래스에 operator+ 메서드가 있다면 앞 문장을 다음과 같이 변환한다.

SpreadsheetCell aThirdCell = myCell.operator+(anotherCell);

단 여기서 operator+의 매개변수가 반드시 이 메서드가 속한 클래스와 같은 타입의 객체만 받을 필요는 없다. SpreadsheetCell에서 operator+를 정의할 때 Spredsheet 매개변수를 받아서 SpreadsheetCell에 더하도록 작성해도 된다.

또한 operator+의 리턴 타입도 마음껏 정할 수 있다. 함수 오버로딩을 떠올려보면 함수의 리턴 타입을 따지지 않았다. 연산자 오버로딩도 일종의 함수 오버로딩이다.

묵시적 변환

놀랍게도 operator+를 정의하면 셀끼리 더할 수 있을 뿐 아니라 셀에 string_view, double, int 같은 값도 더할 수 있다.

SpreadsheetCell myCell(4), aThirdCell;
string str = "hello";
aThirdCell = myCell + string_view(str);
aThirdCell = myCell + 5.6;
aThirdCell = myCell + 4;

이렇게 할 수 있는 이유는 컴파일러가 단순히 operator+만 찾는데 그치지 않고 타입을 정확히 변환할 수 있는 방법도 찾기 때문이다. 또한 지정된 타입을 변환할 방법도 찾는다. 생성자는 이렇게 타입을 변환하는 역할을 하기에 적합하다.

앞서 본 예제에서 컴파일러가 자신에 double 값을 더하는 SpreadsheetCell을 발견하면 먼저 double 타입의 인수를 받는 SpreadsheetCell 생성자를 찾아서 임시 SpreadsheetCell 객체를 생성한 뒤 operator+로 전달한다. SpreadsheetCell에 string_view를 더하는 문장을 발견할 때도 마찬가지다. string_view 타입의 인수를 받는 SpreadsheetCell 생성자를 호출하고 SpreadsheetCell 객체를 임시로 생성해서 operator+로 전달한다.

이렇게 묵시적 변환을 활용하면 편리할 때가 많다. 하지만 앞서 본 예제처럼 SpreadsheetCell에 string_view를 더하는 것은 상식적으로 맞지 않다. SpreadsheetCell에 string_view를 묵시적으로 변환하지 않게 하려면 생성자 앞에 explicit 키워드를 붙인다.

class SpreadsheetCell
{
public:
SpreadsheetCell() = default;
SpreadsheetCell(double initialValue);
explicit SpreadsheetCell(std::string_view initialValue);
}

explicit 키워드는 클래스를 정의하는 코드에서만 지정할 수 있다. 또한 인수를 하나만 지정해서 호출할 수 있는 생성자에만 적합하다. 매개변수가 하나뿐인 생성자나 매개변수를 여러 개 받더라도 디폴트 값이 지정된 생성자에만 붙일 수 있다.

묵시적 변환을 위해 생성자를 선택하는 과정에서 성능이 떨어질 수 있다. 항상 임시 객체를 생성하기 때문이다. double 값을 더할 때 이렇게 임시 객체가 생성되지 않게 하려면 다음과 같이 operator+도 함께 정의한다.

SpreadsheetCell SpreadsheetCell::operator+(double rhs) const
{
return SpreadsheetCell(getValue() + rhs);
}

세 번째 버전: operator+를 전역 함수로 구현하기

묵시적 변환을 활용하면 SpreadsheetCell 객체에 int나 double을 더하도록 operator+를 정의할 수 있다. 하지만 이렇게 하면 다음과 같이 교환 법칙이 성립하지 않는다.

aThirdCell = myCell + 4;  // 정상 작동
aThirdCell = myCell + 5.6; // 정상 작동
aThirdCell = 4 + myCell; // 컴파일 에러
aThirdCell = 5.6 + myCell; // 컴파일 에러

묵시적 변환은 SpreadsheetCell 객체가 연산자의 좌변에 있을 때만 적용된다. 우변에 있을 때는 적용할 수 없다. operator+를 반드시 SpreadsheetCell 객체에 대해 호출해야 하고 이를 위해 객체가 항상 operator+의 좌변에 나와야 하기 때문이다. C++의 문법이 원래 이래서 어쩔 수 없다.

하짐나 클래스에 정의했던 operator+를 전역 함수로 만들면 가능하다. 전역 함수는 특정 객체에 종속적이지 않기 때문이다. 예컨대 다음과 같다.

SpreadsheetCell SpreadsheetCell::operator+(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs)
{
return SpreadsheetCell(lhs.getValue() + rhs.getValue());
}

전역 함수로 정의하려면 연산자를 헤더 파일에 선언해야 한다.

class SpreadsheetCell
{
// 코드 생략
}

SpreadsheetCell operator+(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs);

이제 다음과 같이 덧셈을 적용할 수 있다.

aThirdCell = myCell + 4; // 정상 작동
aThirdCell = myCell + 5.6; // 정상 작동
aThirdCell = 4 + myCell; // 정상 작동
aThirdCell = 5.6 + myCell; // 정상 작동

그렇다면 다음과 같이 작성해도 될까?

aThirdCell = 4.5 + 5.5;

컴파일 에러 없이 제대로 작동하지만 앞에서 정의한 operator+가 호출되지 않고 double 타입에 적용되던 기존 덧셈 연산이 적용돼 다음과 같은 중간 코드 형태로만 변환된다.

aThirdCell = 10;

이 대입문을 셀에 대해 처리하게 하려면 우변에 SpreadsheetCell 객체가 있어야 한다. 따라서 컴파일러는 SpreadsheetCell 클래스에서 explicit이 지정되지 않았으면서 double 타입 인수를 받는 사용자 정의 새엇ㅇ자를 찾아서 double 값을 임시 SpreadsheetCell 객체로 변환한 뒤 대입 연산자를 호출한다.

산술 연산자 오버로딩

(내용 생략)

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

(내용 생략)

비교 연산자 오버로딩

(내용 생략)

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

(생략)

사실 거의 모든 연산자를 오버로딩할 수 있다. 대입, 기본 산술, 축약형 산술, 비교 연산자는 일부에 지나지 않는다. 스트림 추가(stream insertion) 및 추출(stream extraction) 연산자도 오버로딩 하면 편하다. 또한 연산자 오버로딩으로 할 수 있다고 전혀 생각하지 못했던 흥미로운 기능도 구현할 수 있다. 참고로 표준 라이브러리는 이러한 연산자 오버로딩 기능을 상당히 많이 사용하고 있다.

안정적인 인터페이스 만들기

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

인터페이스 클래스는 구현 클래스와 똑같이 public 메서드를 제공하되 구현 클래스 객체에 대한 포인터를 갖는 데이터 멤버 하나만 정의한다. 이를 핌플 이디엄(pimpl idiom, private implementation idiom) 또는 브릿지 패턴(bridge pattern)이라 부른다. 

인터페이스 클래스 메서드는 단순히 구현 클래스 객체에 있는 동일한 메서드를 호출하도록 구현한다. 그러면 구현 코드가 변해도 public 메서드로 구성된 인터페이스 클래스는 영향을 받지 않는다. 따라서 다시 컴파일할 ㅇ리이 줄어든다. 이렇게 정의된 인터페이스 클래스를 사용하는 클라이언트는 구현 코드만 변경됐다면 소스를 다시 컴파일할 필요가 없다. 여기서 주의해야 할 점은 인터페이스 클래스에 존재하는 유일한 데이터 멤버를 구현 클래스에 대한 포인터로 정의해야 제대로 효과를 발휘한다는 것이다. 데이터 멤버가 포인터가 아닌 값 타입이면 구현 클래스가 변경될 때마다 다시 컴파일 해야 한다.

Spreadsheet 클래스에 이 방식을 적용하려면 다음과 같이 Spreadsheet 클래스를 public 인터페이스 클래스로 정의한다.

#include "SpreadsheetCell.h"
#include <memory>

// 포워드 선언
class SpreadsheetApplication;

class Spreadsheet
{
public:
Spreadsheet(const SpreadsheetApplication& theApp, size_t width = kMaxwidth, size_t height = kMaxHeight);
Spreadsheet(const Spreadsheet& src);
~Spreadsheet();

Spreadsheet& operator=(const Spreadsheet& rhs);

void setCellAt(size_t x, size_t y, const SpreadsheetCell& cell);
SpreadsheetCell& getCellAt(size_t x, size_t y);

size_t getId() const;

static const size_t kMaxHeight = 100;
static const sizt_t kMaxWidtdh = 100;

friend void swap(Spreadhseeth& first, Spreadsheet& second) noexcept;

private:
class Impl;
std::unique_ptr<Impl> mImpl;
}

구현 코드는 Impl이란 이름으로 private 중첩 클래스로 정의한다. Spreadsheet 클래스 말고는 구현 클래스에 대해 알 필요가 없기 때문이다. 이렇게 하면 Spreadsheet 클래스는 Impl 인스턴스에 대한 포인터인 데이터 멤버 하나만 갖게 된다. public 메서드는 기존 Spreadsheet 클래스에 있던 것과 같다.

중첩 클래스인 Spreadsheet::Impl의 인터페이스는 기존 Spreadsheet 클래스와 거의 같다. 하지만 Impl 클래스는 Spreadsheet의 private 중첩 클래스이기 때문에 다음과 같이 두 Spreadsheet::Impl 객체를 맞바꾸는 전역 friend swap() 함수를 가질 수 없다.

friend void swap(Spreadsheet::Impl& fisrt, Spreadsheet::Impl& second) noexcept;

따라서 다음과 같이 Spreadsheet::Impl 클래스에서 swap()을 private 메서드로 정의해야 한다.

void swap(Impl& other) noexcept;

구현 코드는 간단하다. 단 중첩 클래스라는 점을 주의한다. 따라서 그냥 Impl::swap()이 아닌 Spreadsheet::Impl::swap()으로 지정해야 한다. 다른 멤버도 마찬가지다. swap() 메서드의 구현 코드는 다음과 같다.

void Spreadsheet::Impl::swap(Impl& other) noexcept
{
using std::swap;

swap(mWidth, other.mWidth);
swap(mHeight, other.mHeight);
swap(mCells, other.mCells);
}

Spreadsheet 클래스는 구현 클래스를 가리키는 unique_ptr을 가지고 있기 때문에 사용자 선언 소멸자가 있어야 한다. 이 소멸자는 특별히 할 일이 없기 때문에 구현 파일에 다음과 같이 디폴트로 지정한다.

Spreadsheet::~Spreadsheet() = default;

이를 통해 특수 멤버 함수를 클래스 정의 뿐만 아니라 구현 파일에서도 디폴트로 지정할 수 있다는 것을 알 수 있다.

setCellAt()이나 getCellAt() 과 같은 Spreadsheet의 메서드에 대한 구현 코드는 들어온 요청을 내부 Impl 객체로 그냥 전달하면 된다.

void Spreadsheet::setCellAt(size_t x, size_t y, const SpreadsheetCell& cell)
{
mImpl->setCellAt(x, y, cell)
}

SpreadsheetCell& Spreadsheet::getCellAt(size_t x, size_t y)
{
return mImpl->getCellAt(x, y);
}

이렇게 하려면 Spreadsheet의 생성자에서 반드시 Impl 객체를 생성하도록 구현해야 한다.

Spreadsheet::Spreadsheet(const SpreadsheetApplication& theApp, size_t width, size_t height)
{
mImpl = std::make_unique<Impl>(theApp, width, height);
}

Spreadsheet::Spreadsheet(const Spreadsheet& src)
{
mImpl = std::make_unique<Impl>(*src.mImpl);
}

복제 생성자 코드가 조금 이상해 보일 수 있다. 원본 스프레드시트(src)의 내부 Impl 객체를 복제해야 하기 때문이다. 복제 생성자는 Impl에 대한 포인터가 아닌 레퍼런스를 인수로 받는다. 따라서 mImpl 포인터를 역참조해서 객체 자체에 접근해야 생성자를 호출할 때 이 레퍼런스를 받을 수 있다.

Spreadsheet의 대입 연산자도 마찬가지로 내부 Impl의 대입 연산자로 전달한다.

Spreadsheet& Spreadsheet::operator=(const Spreadsheet& rhs)
{
*mImpl = *rhs.mImpl;
return *this;
}

대입 연산자의 첫 줄이 이상하게 보일 수 있다. Spreadsheet 대입 연산자는 현재 호출을 Impl의 대입 연산자로 포워딩해야 하는데, 이 연산자는 객체를 직접 복제할 때만 구동된다.

mImpl 포인터를 역참조 하면 강제로 직접 객체 대입 방식을 적용하기 때문에 Impl의 대입 연산자를 호출할 수 있다.

swap() 함수는 다음과 같이 단순히 데이터 멤버를 바꾸기만 한다.

void swap(Spreadsheet& first, Spreadsheet& second) noexcept
{
using std::sawp;
swap(first.mImpl, second.mImpl);
}

이렇게 인터페이스와 구현을 확실히 나누면 엄청난 효과를 얻을 수 있다. 처음에는 좀 번거롭지만 자연스레 이런 스타일로 작성할 수 있다. 

여기서 설명한 것처럼 인터페이스와 구현을 분리하지 않고, 추상 인터페이스(abstract interface), 즉 가상 메서드로만 구성된 인터페이스를 정의한 뒤 이를 구현하는 클래스를 따로 작성해도 된다.

(인터페이스를 다루는 부분이 하도 이상해서 찾아보니 C++은 명시적으로 interface를 지원하지 않는 것 같다. 그래서 좀 이상하게 사용하는 듯. 여기서 소개하는 방식은 너무 번거로운데 10장에서 추상 클래스 형식으로 해도 된다고 하니 그게 내가 알고 있던 interface를 다루는 것과 비슷할 듯)

전문가를 위한 C++/ 클래스와 객체 숙달하기

(전체가 아니라 C#과 차이가 있는 부분을 중심으로 요약 정리)

클래스 작성 방법

클래스 정의

class SpreadsheetCell
{
public:
void setValue(double inValue);
double getValue() const;
private:
double mValue;
}

(클래스 관련 설명 생략)

객체를 변경하지 않는 멤버 함수는 항상 const로 선언하는 것이 바람직하다. (double getValue() const; 부분)

메서드 정의 방법

함수를 만들 때는 프로토타입뿐만 아니라 함수를 구현하는 정의 코드를 함께 작성하듯이 메서드도 프로토타입뿐만 아니라 메서드를 구현하는 정의 코드도 반드시 작성해야 한다. 이때 메서드 정의 코드보다 클래스 정의 코드가 먼저 나와야 한다.

클래스 저으이는 주로 헤더 파일에 작성하고, 메서드 정의는 소스 파일에 작성한 뒤 소스 파일에서 #include 문으로 헤더 파일을 불러오는 방식으로 사용한다. SpreadsheetCell 클래스의 두 메서드를 다음과 같이 작성한다.

#include "SpreadsheetCell.h"

void SpreadsheetCell::setValue(double inValue)
{
mValue = inValue;
}

double SpreadsheetCell::getValue() const
{
return mValue;
}

메서드 이름 앞에 클래스 이름과 콜론 두 개가 붙어 있는데, 여기서 ::를 스코프 지정 연산자라 부른다. 컴파일러가 이 코드를 보면 여기 나온 setValue() 메서드는 SpreadsheetCell 클래스에 속한다는 것을 알 수 있다. 메서드를 구현할 때는 접근 제한자를 생략해도 된다.

(이하 설명 생략)

객체 사용법

객체를 생성해서 사용하는 방법은 크게 두 가지가 있는데, 하나는 스택에 생성하는 것이고 다른 하나는 힙에 생성하는 것이다.

스택에 생성한 객체

SpreadsheetCell myCell, anotherCell;
myCell.setValue(6);
anotherCell.setString("3.2");

cout << "cell 1: " << myCell.getValue() << endl;
cout << "cell 2: " << anotherCell.getValue() << endl;

---
/* 실행 결과
cell 1: 6
cell 2: 3.2
*/

힙에 생성한 객체

다음과 같이 new를 사용해서 객체를 동적으로 생성할 수도 있다.

SpreadsheetCell* myCellp = new SpreadsheetCell();
myCellp->setValue(3.7);

cout << "cell 1: " << myCellp->getValue() << " " << mycellp-getString() << endl;

delete myCellp;
myCellp = nullptr;

힙에 생성한 객체는 화살표 연산자(->)로 멤버에 접근한다. 화살표 연산자는 역참조 연ㅅ나자(*)와 멤버 접근 연산자(.)를 합친 것이다. 화살표 대신 두 연산자르 ㄹ조합해서 사용해도 되지만 코드가 복잡해 진다.

힙에 할당한 메모리를 항상 해제해야 하듯 힙에 할당한 객체 메모리도 반드시 delete로 해제해야 한다. 메모리 관련 문제가 발생하지 않게 하려면 다음과 같이 스마트 포인터를 사용한다.

auto myCellp = make_unique<SpreadsheetCell>();
// 다음과 같이 적어도 된다.
// unique_ptr<SpreadsheetCell> myCellp(new SpreadsheetCell());

myCellp->setValue(3.7);
cout << "cell 1: " << myCellp->getValue() << " " << mycellp-getString() << endl;

객체의 라이프 사이클

객체 생성

(생략)

생성자 사용법

스택에 할당한 SpreadsheetCell 객체의 생성자를 호출하는 방법은 다음과 같다.

SpreadsheetCell myCell(5), anotherCell(4);
cout << "cell 1: " << myCell.getValue() << endl;
cout << "cell 2: " << anotherCell.getValue() << endl;

SpreadsheetCell 객체를 동적으로 할당할 때 생성자를 호출하는 방법은 다음과 같다.

auto smartCellp = make_unique<SpreadsheetCell>(4);
// ... 셀을 다루는 코드를 작성한다.
// 스마트 포인터이므로 직접 삭제 하지 않아도 된다.

// 일반 포인터를 사용해도 되지만 권장하지 않는다.
SpreadsheetCell* myCellp = new SpreadsheetCell(5);
SpreadsheetCell* anotherCellp = nullptr;
anotherCellp = new SpreadsheetCell(4);
// 셀을 다루는 코드를 작성한다.

delete myCellp;
myCellp = nullptr;
delete anotherCellp;
anotherCellp = nullptr;

디폴트 생성자

스택 객체의 디폴트 생성자를 호출하는 방법은 다음과 같다.

SpreadsheetCell myCell;
myCell.setValue(6);
cout << "cell 1: " << myCell.getValue() << endl;

스택 객체의 다른 생성자와 달리 디폴트 생성자는 함수 호출 형식을 따르지 않는다. 다음과 같이 기존 함수 호출 형식에 따라 호출하는 실수를 저지르기 쉽다.

SpreadsheetCell myCell();  // 잘못된 문장이지만 컴파일 오류가 발생하지 않는다.
myCell.setValue(6); // 여기서 컴파일 오류가 발생한다.
cout << "cell 1: " << myCell.getValue() << endl;

어이 없게도 컴파일 에러가 발생하는 지점은 디폴트 생성자를 호출하는 문장이 아닌 그 다음 문장이다. 이런 문제를 가장 짜증나는 파싱 문제(most vexing parse)라고 부른다.

힙 객체의 디폴트 생성자를 호출하는 방법은 다음과 같다.

SpreadsheetCell* myCellp = new SpreadsheetCell();
// 다음과 같이 작성해도 된다.
// SpreadsheetCell* myCellp = new SpreadsheetCell;
// 셀을 다루는 코드를 작성한다.

delete myCellp;
myCellp = nullptr;

C++ 03 이하 버전에서는 인수를 받는 생성자를 여러 개 정의할 때 할 일이 없더라도 빈 디폴트 생성자를 반드시 정의해야 했다. 이렇게 빈 껍데기 디폴트 생성자를 일일이 적는 수고를 덜기 위해 C++에서는 명시적 디폴트 생성자를 제공한다. 이를 이용하면 다음과 같이 클래스 구현 코드에 디폴트 생성자를 작성하지 않아도 된다.

class SpreadsheetCell
{
public:
SpreadsheetCell() = default;
SpreadsheetCell(double initialValue);
}

C+=는 명시적으로 삭제된 생성자란 개념도 지원한다. 예컨대 정적 메서드로만 구성된 클래스를 정의하면 생성자를 작성할 필요가 없을 뿐 아니라 컴파일러가 디폴트 생성자를 만들면 안된다. 이럴 때는 다음과 같이 디폴트 생성자를 명시적으로 삭제해야 한다.

class MyClass
{
public:
MyClass() = delete;
}

생성자 이니셜라이저

C++는 생성자에서 데이터 멤버를 초기화하기 위한 또 다른 방법인 생성자 이니셜라이저(ctor –C++에서는 생성자를 ctor라고 부르기도 한다– 이니셜라이저 또는 멤버 이니셜라이저 리스트)를 제공한다.

SpreadsheetCell::SpreadsheetCell(double initialValue) : mValue(initialValue)
{
}

위 코드에는 생성자 이니셜라이저는 생성자 인수 리스트와 생성자 본문을 시작하는 첫 중괄호 사이에 나온다. 이 구문은 콜론으로 싲가하며 각 항목을 쉼표로 구분한다. 여기 나올 수 있는 항목으로는 함수 호출 형식 또는 유니폼 초기화 문법으로 표현한 데이터 멤버의 이니셜라이저, 베이스 클래스 생성자를 호출하는 구문, 위임된 생성자를 호출하는 구문 등이 있다.

생성자 이니셜라이저로 데이터 멤버를 초기화하는 방식은 생성자 안에서 데이터 멤버를 초기화 하는 것과 다르다. C++에서 객체를 생성하려면 생성자를 호출하기 전에 그 객체를 구성하는 모든 데이터 멤버부터 생성해야 한다. 이렇게 데이터 멤버를 생성하는 과정에서 각 멤버가 다른 객체로 구성됐다면 해당 생성자를 호출한다. 생성자 안에서 객체에 값을 할당하는 시점에는 객체가 이미 생성된 상태다. 여기서는 단지 값을 변경할 뿐이다.

생성자 이니셜라이저를 이용하면 데이터 멤버를 생성하는 과정에서 초깃값을 설정할 수 있는데, 이렇게 하는 것이 나중에 따로 값을 대입하는 것보다 훨씬 효율적이다.

클래스를 구성하는 데이터 멤버에 대해 디폴트 생성자가 정의돼 있다면 생성자 이니셜라이저에서 이 객체를 명싲거으로 초기화하지 않아도 된다. 예컨대 std::string 타입의 데이터 멤버가 있을 때 이 멤버의 디폴트 생성자에 의해 이 멤버의 값을 공백 스트링으로 초기화하기 때문에 이니셜라이저에 “”를 지정하면 같은 코드를 두 번 쓰는 셈이다.

반면 클래스에 있는 데이터 멤버에 대해 디폴트 생성자가 정의돼 있지 않다면 생성자 이니셜라이저를 사용해 그 객체를 적절히 초기화해야 한다. 예컨대 SpreadsheetCell 클래스를 다음과 같이 저으이할 수 있다.

class SpreadsheetCell
{
public:
SpreadsheetCell(double d);
}

이 클래스는 double 타입의 값을 받는 명시적 생성자만 있을 뿐 디폴트 생성자는 없다. 이 클래스의 객체를 다음과 같이 다른 클래스의 멤버로 정의하는 경우를 보자.

class SomeClass
{
public:
SomeClass();
private:
SpreadsheetCell mCell;
}

그러고 나서 SomeClass 생성자를 구현하는 코드를 다음과 같이 작성했다고 하자.

SomeClass::SomeClass() { }

그런데 구현 코드를 이렇게 작성하면 컴파일 에러가 발생한다. SomeClass의 데이터 멤버인 mCell에 대해 디폴트 생성자가 없기 때문에 컴파일러는 mCell을 초기화할 방법을 알 수 없다. 따라서 mCell을 초기화 하려면 다음과 같이 생성자 이니셜라이저를 작성해야 한다.

SomeClass::SomeClass() : mCell(1.0) { }

생성자 안에서 초깃값을 대입하는 방식을 선호하는 프로그래머도 있지만 이렇게 하면 효율성은 좀 떨어진다. 하지만 어떤 데이터 타입은 반드시 생성자 이니셜라이저나 클래스 내부 생성자 구문으로 초기화해야 한다. 다음 표에 이러한 타입을 정리했다.

데이터 타입 설명
Const 데이터 멤버 const 변수가 생성된 후에는 정상적인 방식으로 값을 대입할 수 없다. 반드시 생성 시점에 값을 지정해야 한다.
레퍼런스 데이터 멤버 가리키는 대상 없이는 레퍼런스가 존재할 수 없다.
디폴트 생성자가 정의되지 않은 객체 데이터 멤버 C++에서는 객체 멤버를 디폴트 생성자로 초기화한다. 디폴트 생성자가 없으면 이 객체를 초기화할 수 없다.
디폴트 생성자가 없는 베이스 클래스 10장에서 자세히 설명한다.

생성자 이니셜라이저를 사용할 때 한 가지 주의할 점이 있다. 여기서 나열한 데이터 멤버는 이니셜라이저에서 나열한 순서가 아닌 클래스 정의에 작성한 순서대로 초기화된다는 것이다. 예컨대 다음과 같이 정의된 Foo 클래스를 살펴보자. 여기에 있는 생성자는 단순히 double 값을 저장한 후 콘솔에 출력한다.

class Foo
{
public:
Foo(double value);
private:
double mValue;
}

Foo::Foo(double value) : mValue(value)
{
cout << "Foo::mValue = " << mValue << endl;
}

이때 다음과 같이 Foo 객체를 데이터 멤버로 가지는 MyClass 라는 클래스가 있다고 하자.

class MyClass
{
public:
MyClass(double value);
private:
double mValue;
Foo mFoo;
}

이 클래스의 생성자를 다음과 같이 구현했다.

MyClass::MyClass(double value) : mValue(value), mFoo(mValue)
{
cout << "MyClass::mValue = " << mValue << endl;
}

그러면 생성자 이니셜라이저는 먼저 mValue에 value로 지정한 값을 저장하고나서 Foo 생성자에 mValue를 인수로 전달해서 호출한다. MyClass의 인스턴스는 다음과 같이 생성한다.

My Class instance(1.2);

이렇게 작성한 프로그램의 실행 결과는 다음과 같다.

Foo::mValue = 1.2
MyClass::mValue = 1.2

여기까지는 문제가 없어 보인다. 이번에는 MyClass 정의에서 mValue와 Foo의 위치를 바꿔보자.

class MyClass
{
public:
MyClass(double value);
private:
Foo mFoo;
double mValue;
}

그러면 실행 결과는 다음과 같이 나온다. 구체적인 값은 시스템마다 다르다.

Foo::mValue = -9.25596e+61
MyClass::mValue = 1.2

의도와 전혀 다른 결과가 나오는데, 데이터 멤버는 생성자 이니셜라이저에 나온 순서가 아니라 클래스 정의에 나온 순서대로 초기화 되기 때문이다. 따라서 앞에서 나온 코드처럼 작성하면 Foo 생성자가 호출될 때 초기화되지 않은 mValue가 전달된다. 

참고로 어떤 컴파일러는 클래스 정의에 나온 순서와 생성자 이니셜라이저에 나온 순서가 다르면 경고 메시지를 출력한다.

복제 생성자

C++에서는 복제 생성자라는 특수한 생성자를 제공한다. 다른 객체와 똑같은 객체를 생성할 때 사용한다. 복제 생성자를 작성하지 않으면 컴파일러가 대신 만들어준다. 컴파일러가 생성한 복제 생성자는 데이터 멤버가 기본 타입이면 똑같이 복사하고, 객체 타입이면 그 객체의 복제 생성자를 호출한다.

SpreadsheetCell 클래스에 복제 생성자를 추가하면 다음과 같다.

Class SpreadsheetCell
{
public:
SpreadsheetCell(const SpreadsheetCell& src);
// 나머지 코드 생략
}

복제 생성자는 원본 객체에 대한 const 레퍼런스를 인수로 받는다. 다른 생성자와 마찬가지로 리턴값은 없다. 생성자 안에서 원본 객체에 있는 데이터 멤버를 모두 복사한다. 물론 구체적인 동작은 얼마든지 바꿔도 되지만, 관례를 벗어나지 않는 것이 바람직하므로 새로 만들 객체의 데이터 멤버를 모두 기존 객체의 데이터 멤버로 초기화한다. 이렇게 작성한 SpreadsheetCell의 복제 생성자는 다음과 같다. 특히 생성자 이니셜라이저에 주목한다.

SpreadsheetCell::SpreadsheetCell(const SpreadsheetCell& src) : mValue(src.mValue)
{
}

데이터 멤버가 m1, m2 …. mn과 같이 선언돼 있다면 컴파일러는 다음과 같이 생성자를 만들어 준다.

className::className(const classname& src) : m1(src.m1), m2(src.m2), ... mn(src.mn) { }

따라서 대부분은 복제 생성자를 직접 작성할 필요가 없다.

복제 생성자가 호출되는 경우

C++에서 함수에 인수를 전달할 때 기본적으로 값으로 전달된다(값 전달 방식이 적용된다) 다시 말해 함수나 메서드는 값이나 객체의 복사본을 받는다. 따라서 함수나 메서드에 객체를 전달하면 컴파일러는 그 객체의 복제 생성자를 호출하는 방식으로 초기화한다.

예컨대 다음과 같이 string 매개변수를 값으로 받는 printString() 함수가 있다고 하자.

void printString(string inString)
{
cout << inString << endl;
}

C++에서 제공하는 string 타입은 사실 기본 타입이 아니라 일종의 클래스다. 그래서 코드에서 printString(0에 string 매개변수를 전달해서 호출하면 string 매개변수인 inString은 이 클래스의 복제 생성자를 호출하는 방식으로 초기화된다. 이 복제 생성자의 인수가 바로 printString()에 전달한 string이다. 다음과 같이 printString()에서 매개변수를 name으로 지정해서 호출하면 inString 객체를 초기화할 때 string의 복제 생성자가 실행된다.

string name = "heading one";
printString(name); // name을 복제한다.

printString() 메서드가 실행을 마치면 inString이 삭제된다. 이 값은 실제로 name의 복사본이므로 name은 원래 값 그대로 남아 있다. 물론 복제 생성자에 매개변수를 const 레퍼런스로 전달하면 복제 오버헤드를 줄일 수 있다. (C#에서는 in 이라는 키워드로 비슷한 것을 할 수 있는데 7.2 이상 버전에서 사용 가능하다) 함수에서 객체를 값으로 리턴할 때도 복제 생성자가 호출된다.

복제 생성자 명시적으로 호출하기

복제 생성자를 명시적으로 호출할 수도 있다. 주로 다른 객체를 똑같이 복사하는 방식으로 객체를 만들 때 이 방식을 사용한다. 예컨대 SpreadsheetCell 객체의 복사본을 만들려면 다음과 같이 작성한다.

SpreadsheetCell myCell1(4);
SpreadsheetCell myCell2(myCell1); // myCell2는 myCell1과 같다.
레퍼런스로 객체 전달하기

함수나 메서드에 객체를 레퍼런스로 전달하면 복제 연산으로 인한 오버헤드를 줄일 수 있다. 객체를 레퍼런스로 전달하는 방식이 값으로 전달하는 것보다 대체로 효율적이다. 객체에 있는 내용 전체가 아닌 객체의 주소만 복사하기 때문이다.

객체를 레퍼런스로 전달할 때 그 값을 사용하는 함수나 메서드는 원본 객체를 변경할 수 있다. 단지 성능의 이유로 레퍼런스 전달 방식을 사용한다면 객체가 변경되지 않도록 객체 앞에 const를 붙여야 한다. 이를 const 레퍼런스로 객체를 전달한다고 표현한다.

참고로 SpreadsheetCell 클래스를 보면 std::string_view를 매개변수로 받는 메서드가 몇 개 있다. 2장에서 설명한 것처럼 string_view는 포인터와 길이만 갖고 있다. 그래서 복제 오버헤드가 적기 때문에 주로 값 전달 방식으로 작성한다. (이는 C#에서도 마찬가지라서 객체를 매개변수로 넘길 때 굳이 참조형태로 보낼 필요는 없는 것 같다. 힙의 주소를 가리키는 스택 변수가 복사되기 때문)

int, double 등과 같은 기본 타입은 반드시 값으로 전달해야 한다. 이런 타입을 const 레퍼런스로 전달한다고 해서 크게 나아지지 않는다.

SpreadsheetCell 클래스의 doubleToString() 메서드는 항상 string 객체를 값으로 리턴한다. 이 메서드의 마지막에서 로컬 string 객체를 생성해서 리턴하기 때문이다. 이 string을 레퍼런스로 리턴하면 제대로 작동하지 않는데, 그 레퍼런스가 참조하는 string은 함수가 끝날 때 삭제되기 때문이다.

명시적으로 디폴트로 만든 복제 생성자와 명시적으로 삭제된 복제 생성자

컴파일러가 생성한 복제 생성자를 명싲거으로 티폴트로 만들거나 삭제할 수 있다.

SpreadsheetCell(const SpreadsheetCell& src) = default;
SpreadsheetCell(const SpreadsheetCell& src) = delete;

복제 생성자를 삭제하면 객체를 더 복제할 수 없다. 객체를 값으로 전달하지 ㅇ낳게 할 때 이렇게 설정한다.

이니셜라이저 리스트 생성자

이니셜라이저 리스트 생성자란 std::initializer_list<T>를 첫 번째 매개변수로 받고 다른 매개변수는 없거나 디폴트 매개변수를 추가로 받는 생성자를 말한다. 사용법은 다음과 같다.

class EvenSequence
{
public:
EvenSequence(initializer_list<double> args)
{
if (args.size() % 2 != 0)
{
throw invalid_argument("initializer_list should contain even number of elements");
}
mSequence.reserve(args.size());
for (const auto& value : args)
{
mSequence.push_back(value);
}
}

void dump() const
{
for (const auto& value : mSequence)
{
cout << value << ", ";
}
cout << endl;
}
private:
vector<double> mSequence;
}

이니셜라이저 리스트 생성자 안에서 각 원소에 접근하는 부분을 범위 기반 for 문으로 구현할 수 있다. 이니셜라이저 리스트의 원소 수는 size() 메서드로 알아낼 수 있다.

EvenSequence 클래스에 정의한 이니셜라이저 리스트 생성자는 범위 기반 for 문을 이용해서 initializer_list<T>의 원소를 복사한다. 이렇게 하지 않고 vector의 assign() 메서드를 사용해도 된다.

EvenSequence 객체는 다음과 같이 생성한다.

EvenSequence p1 = { 1.0, 2.0, 3.0, 4.0, 5.0, 6.0 };
p1.dump();

try
{
EvenSequence p2 = { 1.0, 2.0, 3.0 };
}
catch (const invalid_argument& e)
{
cout << e.what() << endl;
}

p2 생성자에서 익셉션이 발생하는데, 이니셜라이저 리스트의 원소 수가 홀수이기 때문이다. 이 문장에서 다음과 같이 등호(대입 연산자)를 생략해도 된다.

EvenSequence p1{ 1.0, 2.0, 3.0, 4.0, 5.0, 6.0 };

표준 라이브러리에 나온 클래스는 모두 이니셜라이저 리스트 생성자를 지원한다. 예컨대 std::vector 컨테이너를 다음과 같이 이니셜라이저 리스트로 초기화할 수 있다.

std::vector<std::string> myVec = { "String 1", "String 2", "String 3" };

이니셜라이저 리스트는 생성자 뿐만 아니라 일반 함수에서도 사용할 수 있다.

위임 생성자

위임 생성자를 사용하면 같은 클래스의 다른 생성자를 생성자 안에서 호출할 수 있다. 하지만 생성자 안에서 다른 생성자를 직접 호출할 수는 없다. 반드시 생성자 이니셜라이저에서 호출해야 하며, 멤버 이니셜라이저 리스트에 이것만 적어야 한다.

SpreadsheetCell::SpreadsheetCell(string_view initialValue) : SpreadsheetCell(stringToDouble(initialValue))
{
}

여기서 string_view 타입 생성자(위임 생성자)가 호출되면 이를 타깃 생성자(여기서는 double 타입 생성자)에 위임한다. 타깃 생성자가 리턴하면 위임 생성자의 코드가 실행된다.

위임 생성자를 사용할 때 다음과 같이 생성자가 재귀적으로 호출되지 않게 주의해야 한다.

class MyClass
{
MyClass(char c) : MyClass(1.2) { }
MyClass(double d) : MyClass('m') { }
}

컴파일러가 생성하는 생성자에 대한 정리

컴파일러는 모든 클래스에 디폴트 생성자와 복제 생성자를 자동으로 만들어준다. 그런데 프로그래머가 직접 작성한 생성자에 따라 컴파일러가 만들어주는 생성자가 달라질 수 있다. 여기세 적용되는 규칙은 다음과 같다.

직접 정의한 생성자 컴파일러가 만들어주는 생성자 객체 생성 방법
없음

디폴트 생성자
복제 생성자

인수가 없는 경우:
SpreadsheetCell cell;
다른 객체를 복제하는 경우:
SpreadsheetCell myCell(cell);
디폴트 생성자만 정의한 경우 복제 생성자 인수가 없는 경우:
SpreadsheetCell cell;
다른 객체를 복제하는 경우:
SpreadsheetCell myCell(cell);
복제 생성자만 정의한 경우 업음 이론적으로는 다른 객체를 복제할 수 있지만 실제로는 어떠한 객체도 생성할 수 없다. 복제 방식을 사용하지 않는 생성자가 없기 때문이다.
한 개의 인수 또는 여러 개의 인수를 받는, 복제 생성자가 아닌 생성자만 정의한 경우 복제 생성자 인수가 있는 경우:
SpreadsheetCell cell(6);
다른 객체를 복제하는 경우:
SpreadsheetCell myCell(cell);
한 개의 인수 또는 여러 개의 인수를 받는, 복제 생성자가 아닌 생성자 또는 디폴트 생성자 하나만 정의한 경우 복제 생성자

인수가 없는 경우:
SpreadsheetCell cell;
인수가 있는 경우:
SpreadsheetCell cell(5);
다른 객체를 복제하는 경우:
SpreadsheetCell anotherCell(cell);

디폴트 생성자와 복제 생성자 사이에 일정한 패턴이 없다는 것을 알 수 있다. 복제 생성자를 명시적으로 정의하지 않는 한 컴파일러는 무조건 복제 생성자를 만든다. 반면 어떤 생성자라도 정의했다면 컴파일러는 디폴트 생성자를 만들지 않는다.

디폴트 생성자와 디폴트 복제 생성자는 명싲거으로 디폴트로 만들거나 삭제하는가에 따라 자동 생성 여부가 결정된다.

객체 소멸

객체가 제거되는 과정은 두 단계로 구성된다. 먼저 객체의 소멸자를 호출한 다음 할당받은 메모리를 반환한다. 객체를 정리하는 작업을 소멸자에서 구체적으로 지정할 수 있다. 동적 메모리를 해제하거나 파일 핸들을 닫는 작업을 여기서 처리할 수 있다.

소멸자를 선언하지 않으면 컴파일러가 하나 만들어주는데, 이를 이요애 멤버를 따라 재귀적으로 소멸자를 호출하면서 객체를 삭제할 수 있다. 

스택 객체는 현재 실행하던 함수, 메서드 또는 코드 블록이 끝날 때와 같이 스코프(유효 범위)를 벗어날 때 자동으로 삭제된다. 다시 말해 코드가 닫는 중괄호를 만날 때마다 중괄호로 묶인 코드의 스택에 생성된 객체가 모두 삭제된다. 예컨대 다음과 같다.

int main()
{
SpreadsheetCell myCell(5);

if (myCell.getValue() == 5)
{
SpreadsheetCell anotherCell(6);
} // 이 블록이 끝날 때 anotherCell이 삭제된다.

cout << "myCell: " myCell.getValue() << endl;
return 0;
} // 이 블록이 끝날 때 myCell이 삭제된다.

스택 객체가 삭제되는 순서는 선언 및 생성 순서와 반대다. 예컨대 다음 코드에서는 myCell2를 먼서 생성한 뒤 anotherCell2를 생성했기 때문에 anotherCell2가 먼저 삭제된 뒤 myCell2가 삭제된다.

{
SpreadsheetCell myCell2(4);
SpreadsheetCell anotherCell2(5);
} // anotherCell2가 먼저 삭제된 뒤 myCell2가 삭제된다.

이러한 순서는 객체로 된 데이터 멤버에 대해서도 똑같이 적용된다.

스마트 포인터를 사용하지 않은 힙 객체는 자동으로 삭제되지 않는다. 객체 포인터에 대해 delete를 명시적으로 호출해서 그 객체의 소멸자를 호출하고 메모리를 해제해야 한다. 예컨대 다음과 같다.

int main()
{
SpreadsheetCell* cellPtr1 = new SpreadsheetCell(5);
SpreadsheetCell* cellPtr2 = new SpreadsheetCell(6);
cout << "cellPtr1: " << cellPtr1->getValue() << endl;
delete cellPtr1;
cellPtr1 = nullptr;
return 0;
} // cellPtr2에 대해 delete를 직접 호출하지 않았기 때문에 삭제됮 ㅣ않는다.

객체에 대입하기

C++ 코드에서 int 값을 다른 곳에 대입할 수 있듯이 객체의 값을 다른 객체에 대입할 수 있다. 

SpreadsheetCell myCell(5), anotherCell;
anotherCell = myCell;

myCell이 anotherCell에 복제된다고 표현하기 쉬운데, C++에서 복제(copy)는 객체를 초기화할 때만 적용되는 표현이다. 이미 값이 할당된 객체를 덮어쓸 때는 대입(assign)이라고 표현한다. 참고로 C++에서 복제 기능은 복제 생성자에서 제공한다. 일종의 생성자이기 때문에 객체를 생성하는데만 사용할 수 있고, 생성된 객체를 다른 값을 대입하는데 쓸 수 없다.

이 때문에 C++는 클래스마다 대입을 수행하는 메서드를 따로 제공한다. 이 메서드를 대입 연산자(assignment operator)라고 부른다. 이 연산자는 클래스에 이는 = 연산자를 오버로딩한 것이기 땜누에 이름이 operator= 이다. 앞서 본 예에서 anotherCell의 대입 연산자는 myCell이란 인수를 전달해서 호출된다.

Note) 여기서 설명한 대입 연산자는 복제 대입 연산자(copy assignment operator)라고 부른다. 좌변과 우변에 있는 객체가 대입 후에도 남아 있기 때문에이다. 이렇게 표현하는 이유는 성능상의 이유로 대입 후에 우변의 객체가 삭제되는 이동 대입 연산자(move assignment operator)와 구분하기 위해서다.

보통의 경우와 마찬가지로 대입 연산자를 직접 정의하지 않아도 된다. C++에서 객체끼리 서로 대입할 수 있도록 자동으로 만들어주기 때문이다. 이렇게 만들어주는 디폴트 대입 연산자는 디폴트 복제 동작과 거의 같다. 즉 원본의 데이터 멤버를 대상 객체로 대입하는 작업을 재귀적으로 수행한다.

대입 연산자 선언 방법

SpreadsheetCell 클래스의 대입 연산자는 다음과 같다.

class SpreadsheetCell
{
public:
SpreadsheetCell& operator=(const SpreadsheetCell& rhs);
}

대입 연산자는 복제 생성자처럼 원본 객체에 대한 const 레퍼런스를 받을 때가 많다. 위 코드에서 소스 객체를 rhs로 표현했는데, 등호의 우변(right-hand side)의 줄임말이다. 대입 연산자가 호출되는 객체는 등호의 좌변(left-hand side)에 있는 객체다.

그런데 대입 연산자는 복제 연산자와 달리 SpreadsheetCell 객체에 대한 레퍼런스를 리턴한다. 그 이유는 다음과 같이 여러 개의 대입 연산이 연달아 있을 수 있기 때문이다.

myCell = anotherCell = aThirdCell;

이 문장이 실해오디면 가장 먼저 anotherCell의 분에 있는 aThirdCell을 대입하는 연산자가 호출되고 그 다음으로 myCell에 대한 대입 연산자가 호출된다. 그런데 이 연산자의 매개변수는 anotherCell이 아니고 aThirdCell을 anotherCell에 대입한 결과가 이 연산의 우변이 된다. 이 대입 연산이 제대로 결과를 리턴하지 않으면 myCell로 아무 것도 전달되지 않는다.

myCell에 대한 대입 연ㅅ나자가 곧바로 anotherCell을 대입하지 않는 이유가 궁금할 것이다. 그 이유는 바로 여기 나온 등호는 실제로 메서드 호출을 간략히 표현한 것에 불과하기 때문이다. 이 문장을 완전히 풀어쓰면 이 관계가 명확히 드러난다.

myCell.operator = (anotherCell.operator = (aThirdCell));

대입 연산자 정의 방법

대입 연산자를 구현하는 방법은 복제 생성자와 비슷하지만 몇 가지 중요한 차이점이 있다. 첫째, 복제 생성자는 초기화할 때 단 한 번만 호출된다. 그 시점에는 타깃 객체가 유효한 값을 갖고 있지 않다. 대입 연산자는 객체에 이미 할당된 값을 덮어쓴다. 그래서 객체에서 메모리를 동적으로 할당하지 않는 한 이 차이점은 크게 드러나지 않는다.

둘째, C++는 객체에 자기 자신을 대입할 수 있다.

SpreadsheetCell cell(4);
cell = cell; // 자기 자신을 대입

따라서 대입 연산자가 구현할 때 자기 자신을 대입하는 경우도 반드시 고려해야 한다.

SpreadsheetCell 클래스에서는 그럴 필요가 없다. 단 하나뿐인 데이터 멤버가 double이란 기본 타입으로 지정되었기 때문이다. 하지만 클래스에 동적으로 할당한 메모리나 다른 리소스가 있다면 자기 자신을 대입하는 작업을 처리하기 쉽지 않다. 이에 대해서는 9장에서 자세히 설명하고, 이런 문제를 해결하려면 연산자를 시작하는 부분에서 자기 자신을 대입하는지 확인해서 곧바로 리턴하게 만들면 된다.

SpreadsheetCell& SpreadsheetCell::operator=(const SpreadsheetCell& rhs)
{
if (this == &rhs)
{
return *this;
}

// 자기 자신이 아니라면 모든 멤버에 대해 대입을 수행한다.
// 대입 연산 수행

// 최종적으로 리턴한다.
return *this;
}

두 객체가 서로 같은지 알아내는 방법 중 하나는 서로 똑같은 메모리 공간에 있는지 확인하는 것이다. 좀 더 구체적으로 표현하면 두 객체에 대한 포인터가 똑같은지 알아보면 된다. this는 메서드가 속한 객체를 가리키는 포인터이므로 포인터가 가리키는 객체는 *this로 표현한다. 컴파일러는 선언된 리턴값과 일치하는 객체에 대한 레퍼런스를 리턴한다.

자기 자신이 아닌 경우에는 모든 멤버에 대해 대입 연산을 수행해야 한 후 *this를 리턴한다.

명시적으로 디폴트로 만들거나 삭제한 대입 연산자

SpreadsheetCell& operator=(const SpreadsheetCell& rhs) = default;
SpreadsheetCell& operator=(const SpreadsheetCell& rhs) = delete;

컴파일러가 만들어주는 복제 생성자와 복제 대입 연산자

C++ 11부터는 클래스에 사용자가 선언한 복제 대입 연산자나 소멸자가 있으면 복제 생성자를 생성해주는 기능을 더는 지원하지 않는다. 이 기능을 계속 사용하고 싶다면 다음과 같이 명시적으로 디폴트를 지정한다.

MyClass(const MyClass& src) = default;

C++ 11부터 클래스에 사용자가 선언한 복제 생성자나 소멸자가 있으면 복제 대입 연ㅅ나자를 생성해주는 기능도 더는 지원하지 않는다. 이 기능을 계속 사용하고 싶다면 다음과 같이 명시적으로 디폴트로 지정한다.

MyClass& operator=(const MyClass& rhs) = default;

복제와 대입 구분하기

객체를 복제 생성자로 초기화할지 대입 연산자로 대입할지 구분하기 힘들 때가 있다. 기본적으로 선언처럼 생겼다면 복제 생성자를 사용하고, 대입문처럼 생겼다면 대입 연산자로 처리한다.

SpreadsheetCell myCell(5);
SpreadsheetCell anotherCell(myCell);

anotherCell은 복제 생성자를 이용하여 만든다.

SpreadsheetCell aThirdCell = myCell;

aThirdCell도 복제 생성자를 이용하여 만든다. 선언문이기 때문이다. 위 문장에서는 operator=이 호출되지 않는다. 이는 SpreadsheetCell aThirdCell(myCell);의 다른 표현에 불과하다.

하지만 다음과 같이 작성하면

anotherCell = myCell;

anotherCell이 이미 생성되었기 때문에 컴파일러는 operator=를 호출한다.

리턴값이 객체인 경우

함수나 메서드에서 객체를 리턴할 때 복제될지 대입될지 판단하기 힘들 때가 있다. 예컨대 다음 코드를 살펴보자.

string SpreadsheetCell::getString() const
{
return doubleToString(mValue);
}

이 메서드를 다음과 같이 호출하는 경우를 보자.

SpreadsheetCell myCell2(5);
string s1;
s1 = myCell2.getString();

getString()이 스트링을 리턴할 때 컴파일러는 string의 복제 생성자를 호출해서 이름 없는 임시 string 객체를 생성한다. 이 객체를 s1에 대입하면 s1의 대입 연산자가 호출되는데 이 연산자의 매개변수로 방금 만든 임시 string 객체를 전달한다. 그런 다음 임시로 생성한 string 객체를 삭제한다. 따라서 이 한 줄의 코드 안에서 복제 생성자와 대입 연산자가 서로 다른 두 객체에 대해 호출된다.

하지만 컴파일러마다 다르게 처리할 수 있으며, 값을 리턴할 때 복제 생성자의 오버헤드가 크다면 리턴값 최적화(Return Value Optimization, RVO) 또는 복제 생략(copy elision)을 적용해서 최적화 하기도 한다.

SpreadsheetCell myCell3(5);
string s2 = myCell3.getString();

여기서도 getString()은 리턴할 때 이름 없는 임시 string 객체를 생성한다. 하지만 이번에는 s2에서 대입 연산자가 아닌 복제 생성자가 호출된다.

이동 의미론에 따르면 getString()에서 스트링 값을 리턴할 때 컴파일러는 복제 생성자 대신 이동 생성자를 사용한다. 이렇게 하는 편이 더 효율적인데, 자세한 내용은 9장에서 설명한다.

복제 생성자와 객체 멤버

생성자에서 대입 연산자를 호출할 떄와 복제 생성자를 호출할 때의 차이점도 잘 알아둘 필요가 있다. 어떤 객체가 다른 객체를 담고 있다면 컴파일러에서 만들어준 복제 생성자는 객체에 담긴 객체의 복제 생성자를 재귀적으로 호출한다.

복제 생성자를 직접 정의했다면 앞서 본 생성자 이니셜라이저를 이용해서 이런 메커니즘을 직접 구현한다. 이때 생성자 이니셜라이저에서 데이터 멤버를 생략하면 생성자 본문에 작성된 코드를 실행하기 전에 컴파일러가 그 멤버에 대한 (디폴트 생성자를 호출해서) 초기화 작업을 처리해준다. 따라서 생성자의 본문을 실행할 시점에는 데이터 멤버가 모두 초기화된 상태다.

예컨대 다음과 같이 복제 생성자를 작성한 경우를 보자.

SpreadsheetCell::SpreadsheetCell(const SpreadsheetCell& src)
{
mValue = src.mValue;
}

그런데 복제 생성자 본문 안에서 데이터 멤버에 값을 대입하면 복제 생성자가 아닌 대입 연산자가 적용된다. 데이터 멤버가 이미 초기화된 상태이기 때문이다.

복제 생성자를 다음과 같이 작성하면 mValue는 복제 생성자를 사용해서 초기화된다.

SpreadsheetCell::SpreadsheetCell(const SpreadsheetCell& src) : mValue(src.mValue)
{
}

전문가를 위한 C++/ 메모리 관리

(전체가 아니라 C#과 차이가 있는 부분을 중심으로 요약 정리)

동적 메모리 다루기

메모리의 작동 과정 살펴보기

int i = 7;

위와 같은 로컬 변수 i를 자동 변수(automatic variable)이라 부르며, 이는 스택에 저장된다. 프로그램의 실행 흐름이 이 변수가 선언된 스코프(유효 범위)를 벗어나면 메모리가 자동으로 해제된다.

int* ptr = new int;

new 키워드를 사용하면 힙 메모리가 할당된다. 포인터 역시 일종의 변수이기 때문에 ptr이라는 변수는 스택에 저장되지만, 이 변수가 가리키는 값은 힙에 있다. (이는 C#에서도 동일한 식으로 관리된다. 힙에 할당된 데이터를 가리키는 스택 변수가 있다. 이는 scope를 벗어난 후에도 데이터를 사용하기 위해 스택과는 다른 공간에 메모리를 할당하는 것이다)

int** handle = nullptr;
handle = new int*;
*handle = new int;

위 코드는 포인터가 스택과 힙에 모두 있는 예를 보여준다. 먼저 정수 포인터에 대한 포인터를 handle이란 변수로 선언한다. 그런 다음 정수 포인터를 담는데 충분한 크기로 메모리를 할당한 뒤 그 메모리에 대한 포인터를 handle에 저장했다. 이어서 이 메모리(*handle)에 정수를 담기 충분한 크기의 힙 메모리를 동적으로 할당했다. 이렇게 되면 두 포인터 중 하나(handle)은 스택에, 다른 하나(*handle)은 힙에 존재하게 된다.

메모리 할당과 해제

new와 delete 사용법

변수에 필요한 메모리 블록을 할당하려면 new에 그 변수의 타입을 지정해서 호출한다. 그러면 할당한 메모리에 대한 포인터가 리턴된다.

물론 이 포인터를 변수에 저장하는 등 관리하는 작업은 프로그래머의 몫이다. new의 리턴값을 무시하거나 그 포인터를 담았던 변수가 스코프를 벗어나면 할당했던 메모리에 접근할 수 없는데, 이를 메모리 누수(memory leak)이라 부른다.

void leaky()
{
new int; // 메모리 누수가 발생한다.
}

위 코드는 int를 담을 공간만큼 메모리 누수가 발생하는 예를 보여준다. 스택에서 직접적이든 간접적이든 더는 접근할 수 없는 데이터 블록이 힙에 발생하면 메모리 누수가 발생한다.

힙 메모리를 해제하려면 delete 키워드에 해제할 메모리를 가리키는 포인터를 지정한다.

int* ptr = new int;
delete ptr;
ptr = nullptr;

Note) 메모리를 해제한 포인터는 nullptr로 다시 초기화한다. 그래야 이미 해제된 메모리를 가리키는 포인터를 모르고 다시 사용하는 실수를 방지할 수 있다.

malloc()

C++은 여전히 malloc()을 지원하지만 malloc() 대신 new를 사용하는 것이 바람직하다. new는 단순히 메모리를 할당하는데 그치지 않고 객체까지 만들기 때문이다.

(이하 생략)

Caution) C++에서는 malloc()과 free()를 절대 사용하지 말고 new와 delete만 사용한다.

메모리 할당에 실패한 경우

new가 항상 제대로 처리될 것이라 생각하는 사람이 적지 않지만 메모리가 부족해서 상황이 무지무지 좋지 않으면 new가 실패할 수 있다. 기본적으로 new가 실패하면 프로그램이 종료된다. 

(이하 설명 생략)

배열

기본 타입 배열

프로그램에서 배열에 대한 메모리를 할당하면 실제 메모리에서도 연속된 공간을 할당한다. 이때 메모리의 한 칸은 배열의 한 원소를 담을 수 있는 크기로 할당된다. 예컨대 다섯 개의 int 값으로 구성된 배열을 다음과 같이 로컬 변수로 선언하면 스택에 메모리가 할당된다. (C++은 놀랍게도 배열을 스택에 만들 수 있다!)

int myArray[5];

배열을 힙에 선언할 때도 비슷하다. 배열의 위치를 가리키는 포인터를 사용한다는 점만 다르다. 다음 코드는 int 값 5개를 담는 배열에 메모리를 할당해서 그 공간을 가리키는 포인터를 myArrayPtr이란 변수에 저장한다.

int* myArrayPtr = new int[5];

(이하 설명 생략)

명심해야 할 점은 new[]를 호출한 만큼 delete[]도 호출해야 한다는 것이다.

C++은 realloc() 이란 함수도 지원하는데, 이 함수 역시 C 언어에서 물려받은 것이므로 절대 사용하지 말기를 바란다.

객체 배열

(생략)

배열 삭제하기

(생략)

다차원 배열

(생략. 다차원 배열도 스택과 힙에 각각 생성할 수 있다.)

힙에서는 메모리 공간이 연속적으로 할당되지 않기 때문에, 스택 방식의 다차원 배열처럼 메모리를 할당하면 안 된다. 이럴 때는 힙 배열의 첫 번째 인덱스에 해당하는 배열을 연속적인 공간에 먼저 할당한다. 그런 다음 이 배열의 각 원소에 두 번째 인덱스에 해당하는 차원의 배열을 가리키는 포인터를 저장한다.

아쉽지만 여기서 하위 배열을 할당하는 작업은 컴파일러가 자동으로 처리할 수 없기 때문에 직접 할당해야 한다.

char** allocateCharacterBoard(size_t xDimension, size_t yDimension)
{
char** myArray = new char*[xDimension]; // 첫 번째 차원의 배열을 할당한다.

for (size_t i = 0; i < xDimension; i++)
{
myArray[i] = new char[yDimension[; // i번째 하위 배열을 할당한다.
}
return myArray;
}

다차원 힙 배열에 할당된 메모리를 해제할 때도 마찬가지로 delete[]로 하위 배열을 해제할 수 없기 때문에 일일히 해제해야 한다. 

void releaseCharacterBoard(char** myArray, size_t xDimension)
{
for (size_t i = 0; i < xDimension; i++)
{
delete [] myArray[i]; // i번째 하위 배열을 해제한다.
}
delete [] myArray; // 첫 번째 차원의 배열을 해제한다.
}

기존 C 스타일 배열은 메모리 안정성이 떨어지므로 가급적 사용하지 않는 것이 좋다. 여기서 C 스타일 배열도 소개한 이유는 기존에 작성된 코드를 봐야 할 수도 있기 때문이다. 코드를 새로 작성할 때는 std::array나 std::vector와 같은 C++ 표준 라이브러리에서 제공하는 컨테이너를 사용하기 바란다.

일차원 동적 배열은 vector<T>로, 이차원 동적 배열은 vector<vector<T>>로 작성한다. 행의 길이가 모두 같은 이차원 데이터를 다루고 싶다면 Matrix<T>나 Table<T> 클래스 템플릿을 이용한다.

포인터 다루기

포인터는 남용하기 쉬운 기능으로 악명 높다. 포인터는 단지 메모리 주소이기 때문에 이론상 그 주소를 얼마든지 변경할 수 있고, 심지어 다음과 같이 위험한 일도 할 수 있다.

char* scaryPointer = (char*)7;

이 코드는 메모리 주소 7에 대한 포인터를 만드는데, 이 포인터는 어떤 값을 가리키거나 애플리케이션의 다른 영역에서 사용하는 공간일 가능성이 높다.

포인터의 작동 방식

포인터는 메모리의 한 지점을 가리키는 숫자에 불과하다. 공간적 사고에 익숙한 사람은 포인터를 화살표로 생각하면 이해하기 쉽다. 포인터는 손가락으로 가리키는 것처럼 참조 단계를 표현한다. 이 관점에서 보면 여러 단계로 구성된 포인터에서 각 단계는 데이터에 이르는 경로라고 볼 수 있다.

*연산자로 포인터를 역참조하면 메모리에서 한 단계 더 들어가 볼 수 있다. 포인터를 주소 관점에서 보면 역참조는 포인터가 가리키는 주소로 점프하는 것과 같다. 역참조를 하는 부분을 그림으로 표현하면 출발 지점에서 목적지로 향하는 화살표로 나타낼 수 있다.

& 연산자를 사용하면 특정 지점의 주소를 얻을 수 있다. 이렇게 하면 메모리에 대한 참조 단계가 하나 더 늘어난다. 이 연산자를 주소 관점에서 보면 프로그램은 특정 특정 메모리 지점을 숫자로 표현한 주소로 본다. 공간 관점에서 보면 표현식의 결과가 담긴 위치를 가리키는 화살표를 생성한다고 볼 수 있다. 그리고 이 화살표가 시작하는 지점을 포인터로 저장할 수 있다.

포인터에 대한 타입 캐스팅

포인터는 단지 메모리 주소에 불과해서 타입을 엄격히 따지지 않는다. XML 문서를 가리키는 포인터와 정수를 가리키는 포인터는 크기가 서로 같다. 프로인터 타입은 C 스타일 캐스팅을 이용해서 얼마든지 바꿀 수 있다.

Document* documentPtr = getDocument();
char* myCharPtr = (char*)documentPtr;

정적 캐스팅을 사용하면 좀 더 안전하다. 그러면 관련 없는 데이터 타입으로 포인터를 캐스팅하면 컴파일 에러가 발생한다.

Document* documentPtr = getDocument();
char* myCharPtr = static_cast<char*>(documentPtr);  // 컴파일 에러가 발생한다.

정적 캐스팅 하려는 포인터와 캐스팅 결과에 대한 포인터가 가리키는 객체가 서로 상속 관계에 있다면 컴파일 에러가 발생하지 않는다.

배열과 포인터의 두 얼굴

배열=포인터

힙 배열을 참조할 때만 포인터를 사용하는 것은 아니다. 스택 배열에 접근할 때도 포인터를 사용할 수 있다. 배열의 주소는 사실 (인덱스가 0인) 첫 번째 원소에 대한 주소다. 컴파일러는 배열의 변수 이름을 보고 배열 전체를 가리킨다고 알지만, 실제로는 배열의 첫 번째 원소에 대한 주소만 가리킬 뿐이다. (배열은 메모리 공간에 연속적으로 존재한다)

그래서 힙 배열과 똑같은 방식으로 포인터를 사용할 수 있다. 다음 코드는 0으로 초기화한 스택 배열을 만들고 포인터로 접근하는 예를 보여준다.

int myIntArray[10];
int* myIntPtr = myIntArray;
// 포인터로 배열 접근하기
myIntPtr[4] = 5;

스택 배열을 포인터로 접근하는 기능은 배열을 함수에 넘길 때 특히 유용하다. 다음 함수는 정수 배열을 포인터로 받는다. 여기서 함수를 호출할 때 배열의 크기를 지정해야 하는데, 포인터만으로는 크기를 알 수 없기 때문이다. 사실 C++에서 배열은 원소의 타입이 포인터가 아니더라도 크기 정보를 다루지 않는데, 이 점은 표준라이브러리에서 제공하는 컨테이너를 사용해야 하는 또 다른 이유이기도 하다. (C++이 오래된 언어라서 그런지 현재 기준으로 보면 납득 안되는 부분이 좀 있는 것 같다.)

void doubleInts(int* theArray, size_t size)
{
for (size_t i = 0; i < size; i++)
{
theArray[i] *= 2;
}
}

이 함수를 호출할 때 스택 배열을 전달해도 되고 힙 배열을 전달해도 된다. 힙 배열을 전달하면 이미 포인터가 담겨 있어서 함수에 값으로 전달된다. 스택 배열을 전달하면 배열 변수를 전달하기 때문에 컴파일러가 이를 배열에 대한 포인터로 변환한다. 이때 프로그래머가 직접 첫 번째 원소의 주소를 넘겨도 된다. 세 경우를 코드로 표현하면 다음과 같다.

size_t arrSize = 4;
int* heapArray = new int[arrSize] { 1, 5, 3, 4 };
doubleInts(heapArray, arrSize);
delete [] heapArray;
heapArray = nullptr;

int stackArray[] = { 5, 7, 9, 11 };
arrSize = std::size(stackArray); // C++17부터는 <array>를 사용한다.
//arrSize = sizeof(stackArray) / sizeof(stackArray[0]); // C++17 이전 방식
doubleInts(stackArray, arrSize);
doubleInts(&stackArray[0], arrSize);

배열을 매개변수로 전달하는 과정은 포인터를 매개변수로 전달할 때와 섬뜩할 정도로 비슷하다. 컴파일러는 배열을 함수로 전달하는 부분을 포인터로 취급한다. 배열을 인수로 받아서 그 안에 담긴 값을 변경하는 함수는 복사본이 아닌 원본을 직접 수정한다.

포인터와 마찬가지로 배열을 전달하면 시제로 레퍼런스 전달 방식의 효과가 나타난다. 함수에 전달한 값이 복사본이 아닌 원본 배열의 주소이기 때문이다. 다음에 나온 doubleInts() 코드는 포인터가 아닌 배열 매개변수를 받더라도 원본 배열이 변경되는 것을 보여준다.

void doubleInts(int theArray[], size_t size)
{
for (size_t i = 0; i < size; i++)
{
theArray[i] *= 2;
}
}

컴파일러는 이 함수의 프로토타입에서 theArray 뒤의 대괄호([]) 사이에 나온 숫자를 무시한다. 그래서 다음 세 가지 방식으로 표현한 문장은 모두 같다.

void doubleInts(int* theArray, size_t inSize);
void doubleInts(int theArray[], size_t inSize);
void doubleInts(int theArray[2], size_t inSize);

왜 이렇게 처리하는지 이해가지 않을 수 있다. 함수 정의 부분에 배열 문법을 사용하면 컴파일러가 그 배열을 복사해야 한다고 생각할 수도 있다. 하지만 그렇게 하지 않은 이유는 성능 때문이다. 배열에 담긴 원소를 모두 복사하는데 시간이 걸릴 뿐 아니라, 메모리 공간도 상당히 차지한다. 이처럼 항상 포인터를 전달하기 때문에 컴파일러가 배열을 복사하는 코드를 추가할 필요가 없다.

길이를 알고 있는 스택 배열을 레퍼런스 전달 방식으로 함수에 전달하는 방법도 있는데, 문법이 깔끔하지 않다. 이 방식은 힙 배열에 적용할 수 없다.

void doubleIntsStack(int (&theArray)[4]);

12장에서 설명할 함수 템플릿을 사용하면 스택 배열의 크기를 컴파일러가 알아낼 수 있다.

template<size_t N>
void doubleIntsStack(int (&tgeArray)[N])
{
for (size_t i = 0; i <N; i++)
{
theArray[i] *= 2;
}
}

포인터가 모두 배열은 아니다!

포인터와 배열이 같다고 생각하면 안된다. 사실 미묘하지만 중요한 차이가 있다. 포인터 자체는 의미가 없다. 임의의 메모리를 가리킬 수도 있고 객체나 배열을 가리킬 수 있다. 언제든지 포인터에 배열 문법을 적용해도 되지만 실제로 포인터가 배열은 아니기 때문에 부적절한 경우도 있다.

int* ptr = new int;

ptr이란 포인터는 정상적인 포인터지만 배열은 아니다. 이 포인터가 가리키는 값을 배열 문법(ptr[0])으로 표현할 수는 있지만 바람직한 작성 방식이 아닐 뿐만 아니라 좋은 점도 없다. 사실 이렇게 배열이 아닌 포인터를 배열 문법으로 표현하면 버그가 발생하기 쉽다. ptr[1]에 있는 메모리에 어떤 값이 있을지 모르기 때문이다.

로우레벨 메모리 연산

C보다 C++이 좋은 점 중 하나는 메모리에 신경을 덜 쓸 수 있다는 것이다. 객체를 이용할 때는 메모리 관리를 클래스 단위로만 신경 쓰면 된다. 생성자와 소멸자를 통해 메모리 관리 작업을 해야 할 시점만 알려주면 나머지 작업은 컴파일러가 도와준다. 이렇게 메모리 관리 작업을 클래스 단위로 숨기면 사용성이 크게 높아진다.

포인터 연산

C++ 컴파일러는 포인터 연산을 수행할 때 그 포인터에 선언된 타입을 이용한다. 포인터를 int로 선언하고 그 값을 1만큼 증가시키면 포인터는 메모리에서 한 바이트가 아닌 int 크기만큼 이동한다.

이 연산은 주로 배열을 다루는데 유용한데, 배열에 담긴 데이터는 모두 타입이 같을 뿐만 아니라 메모리에 연속적으로 저장되어 있기 때문이다.

(이하 생략)

커스텀 메모리 관리

C++에서는 기본으로 제공하는 메모리 할당만으로도 대부분의 일을 처리할 수 있다. new와 delete의 내부 처리 과정을 살펴보면 메모리르 ㄹ적절한 크기로 잘라서 전달하고, 현재 메모리에서 사용할 수 있는 공간을 관리하고, 다쓴 메모리를 해제하는데 필요한 작업을 수행한다.

리소스가 상당히 부족하거나 메모리 관리와 같은 특수한 작업을 수행할 때는 메모리를 직접 다뤄야 할 수도 있다. 핵심은 클래스에 큰 덩어리의 메모리를 할당해놓고 필요할 때마다 잘라 쓰는데 있다.

메모리를 직접 관리하면 뭐가 좋을까? 오버헤드를 좀 더 줄일 수 있다. 여기서 오버헤드란 new로 메모리를 할당하면 현재 프로그램에서 얼마나 할당했는지 기록하는데 필요한 공간을 말한다. 이렇게 기록해둬야 delete를 호출할 때 딱 필요한 만큼 해제할 수 있다.

가비지 컬렉션

메모리를 정상 상태로 유지하기 위한 최후의 보루는 가비지 컬렉션(garbage collection)이다. C++은 자바나 C#과 달리 가비지 컬렉션이 기본으로 제공되지 않는다. 최신 버전 C++은 스마트 포인터로 메모리를 관리해서 나아졌지만, 예전에는 new와 delete를 이용해서 직접 관리해야 했다.

shared_ptr 같은 스마트 포인터는 가비지 컬렉션과 상당히 비슷한 방식으로 메모리를 관리한다.

(이하 설명 생략)

가비지 컬렉션 메커지늠을 구현하기는 상당히 어렵고 속도가 느릴 가능성이 높기 때문에 굳이 애플리케이션 안에 가비지 컬렉션 기능을 구현하고 싶다면 재사용 가능한 형태로 구현되어 있는 가비지 컬렉션 라이브러리를 찾아서 쓰기 바란다.

객체 풀

객체 풀은 접시를 재사용하는 것에 비유할 수 있다. 사용할 접시 수를 미리 정해놓고, 음식을 먹고 난 빈 접시에 다시 음식을 담아오는 것이다. 객체 풀은 타입이 같은 여러 개의 객체를 지속적으로 사용해야 하지만 매번 객체를 생성하면 오버헤드가 상당히 커지는 상황에 적용하기 좋다.

스마트 포인터

메모리 누수를 방지하려면 스마트 포인터를 사용하는 것이 좋다. 기본적으로 스마트 포인터는 메모리뿐만 아니라 동적으로 할당한 모든 리소스를 가리킨다. 스마트 포인터가 스코프를 벗어나거나 리셋되면 거기에 할당된 리소스가 자동으로 해제된다. 

스마트 포인터는 함수 스코프 안에서 동적으로 할당된 리소스를 관리하는데 사용할 수도 있고, 클래스의 데이터 멤버로 사용할 수도 있다. 동적으로 할당된 리소스의 소유권을 함수의 인수로 넘겨줄 때도 스마트 포인터를 사용한다.

C++은 스마트 포인터를 지원하는 기능을 언어 차원에서 다양하게 제공한다. 첫째, 템플릿을 이용하면 모든 포인터 타입에 대해 타입에 안전한 스마트 포인터 클래스를 사용할 수 있다. 둘째, 연산자 오버로딩을 이용하여 스마트 포인터 객체에 대한 인터페이스를 제공해서 스마트 포인터 객체를 일반 포인터처럼 활용할 수 있다.

스마트 포인터의 종류는 다양하다. 가장 간단한 것은 리소스에 대한 고유 소유권을 받는 것이다. 그래서 스마트 포인터가 스코프를 벗어나거나 리셋되면 참조하던 리소스를 해제한다. 표준 라이브러리에서 제공하는 std::unique_ptr가 이러한 고유(단독) 소유권 방식을 지원한다.

포인터를 관리하는 과정에서 발생하는 문제는 단순히 스코프를 벗어날 때 해제하는 것을 깜빡 잊는 것 말고도 많이 있다. 간혹 어떤 포인터의 복사본을 여러 객체나 코드에서 갖고 있을 때가 있다. 이러한 상황을 앨리어싱(aliasing)이라 부른다.

모든 리소스를 제대로 해제하려면 리소스를 마지막으로 사용한 포인터가 해제해야 한다. 그런데 코드의 어느 지점에서 그 리소스를 마지막으로 사용하는지 알기 힘들 때가 많다. 실행 시간에 입력되는 값에 따라 동작이 결정된다면 정확한 순서를 알아내는 것은 근본적으로 불가능하다. 그래서 리소스의 소유자를 추적하도록 레퍼런스 카운팅(reference counting)을 구현한 스마트 포인터도 있다. 

이 기능이 지원되는 스마트 포인터를 복사해서 리소스를 가리키는 인스턴스가 새로 생성되면 레퍼런스 카운트가 증가한다. 또한 이렇게 복사해서 만든 스마트 포인터 인스턴스가 스코프를 벗어나거나 리셋되면 레퍼런스 카운트가 감소한다. 레퍼런스 카운트가 0이되면 그 리소스를 사용하는 곳이 없기 때문에 스마트 포인터에 의해 자동으로 해제된다. 표준 라이브러리에서 제공하는 std::shared_ptr가 바로 이러한 레퍼런스 카운팅을 이용하여 공유 소유권(shared ownership) 방식을 지원한다. C++ 표준에서 정한 shared_ptr은 스레드에 안전하다. 그렇다고 해서 포인터가 가리키던 리소스도 스레드에 안전하다는 뜻은 아니다.

Note) 주로 unique_ptr을 사용하고 shared_ptr은 리소스를 공유할 때만 사용한다.

unique_ptr

동적으로 할당한 리소스는 항상 unique_ptr와 인스턴스에 저장하는 것이 바람직하다.

unique_ptr 생성 방법

void couldBeLeaky()
{
Simple* mySimplePtr = new Simple();
mySimplePtr->go();
delete mySimplePtr;
}

위 코드는 Simple 객체를 동적으로 할당해서 사용한 후 delete를 호출한다. 메모리를 제대로 했다고 생각할 수 있지만 go() 메서드 안에서 익센셥이 발생하면 delete가 실행되지 않기 때문에 메모리 누수가 발생할 가능성은 남아 있다.

이 코드를 unique_ptr로 구현하면 객체에 대해 delete를 직접 호출하지 않고도 Simple 객체가 자동으로 해제된다.

void notLeaky()
{
auto mySimpleSmartPtr = make_unique<Simple>();
mySimpleSmartPtr->go();
}

위 코드는 C++ 14부터 제공하는 make_unique()와 auto 키워드를 동시에 적용했다. 만일 make_unique()를 지원하지 않는 컴파일러를 사용한다면 다음과 같이 unique_ptr로 생성한다.

unique_ptr<Simple> mySimpleSmartPtr(new Simple());

C++ 17 이전에는 타입을 단 한 번만 지정하기 위해 뿐만 아니라 안전을 위해 반드시 make_unique()를 사용해야 했다. 다음의 함수를 보자.

foo(unique_ptr<Simple>(new Simple()), unque_ptr<Bar>(new Bar(data())));

Simple이나 Bar의 생성자 또는 data() 함수에서 익셉션이 발생하면 Simple이나 Bar 객체에 메모리 누수가 발생할 가능성이 매우 높다. 하지만 make_unique()를 사용하면 누수가 발생하지 않는다.

foo(make_unique<Simple>(), make_unique<Bar>(data()))

unique_ptr 사용 방법

표준 스마트 포인터의 대표적인 장점은 문법을 새로 익히지 않고도 향상된 기능을 누릴 수 있다는 것이다. 스마트 포인터는 일반 포인터와 똑같이 *나 ->로 역참조 한다.

mySimpleSmartPtr->go();

다음과 같이 일반 포인터처럼 작성해도 된다.

(*mySimpleSmartPtr).go();

get() 메서드를 이용하면 내부 포인터에 직접 접근할 수 있다. 일반 포인터만 전달할 수 있는 함수에 스마트 포인터를 전달할 때 유용하다. 예컨대 다음과 같은 함수가 있다고 하자.

void processData(Simple* simple) { /* 스마트 포인터를 사용하는 코드 */ }

그러면 이 함수를 다음과 같이 호출할 수 있다.

auto mySimpleSmartPtr = make_unique<Simple>();
processData(mySimpleSmartPtr.get());

reset()을 사용하면 unique_ptr의 내부 포인터를 해제하고 필요하다면 다른 포인터로 변경할 수 있다.

mySimpleSmartPtr.reset();  // 리소스 해제 후 nullptr로 초기화
mySimpleSmartPtr.reset(new Simple()); // 리소스 해제 후 새로운 Simple 인스턴스로 설정

release()를 이용하면 unique_ptr과 내부 포인터의 고나계를 끊을 수 있다. release() 메서드는 리소스에 대한 내부 포인터를 리턴한 뒤 스마트 포인터를 nullptr로 설정한다. 그러면 스마트 포인터는 그 리소스에 대한 소유권을 잃으며, 리소스를 다 쓴 뒤 반드시 직접 해제해야 한다.

Simple* simple = mySimpleSmartPtr.release();  // 소유권을 해제한다.
// simple 포인터를 사용하는 코드
delete simple;
simple = nullptr;

unique_ptr은 단독 소유권을 표현하기 때문에 복사할 수 없다. std::move() 유틸리티를 사용하면 하나의 unique_ptr을 다른 곳으로 이동할 수 있는데, 복사라기 보다는 이동의 개념이다. 다음과 같이 소유권을 명시적으로 이전하는 용도로 많이 사용한다.

class Foo
{
public:
Foo(unique_ptr<int> data) : mData(move(data)) { }
private:
unique_ptr<int> mdDta;
}

auto myIntSmartPtr = make_unique<int>(42);
Foo f(move(myIntSmartPtr));

unique_ptr과 C 스타일 배열

unique_ptr은 기존 C 스타일의 동적 할당 배열을 저장하는데 적합하다. 예컨대 정수 10개를 가진 C 스타일의 동적 할당 배열을 다음과 같이 표현할 수 있다.

auto myVariableSizedArray = make_unique<int[]>(10);

이렇게 unique_ptr로 C 스타일의 동적 할당 배열을 저장할 수는 있지만, 이보다는 std::array나 std::vector와 같은 표준 라이브러리 컨테이너를 사용하는 것이 바람직하다.

커스텀 제거자

기본적으로 unique_ptr은 new와 delete로 메모리를 할당하거나 해제한다. 하지만 다음과 같이 방식을 변경할 수 있다.

int* malloc_int(int value)
{
int* p = (int*)malloc(sizeof(int));
*p = value;
return p;
}

int main()
{
unique_ptr<int, decltype(free)*> myIntSmartPtr(malloc_int(42), free);
return 0;
}

이 코드는 malloc_int()로 정수에 대한 메모리를 할당한다. unique_ptr은 메모리를 표준 free() 함수로 해제한다. 앞서 C++에서는 malloc()이 아니라 new를 사용해야 한다고 했는데, unique_ptr에서 이 기능을 제공하는 이유는 메모리가 아닌 다른 리소스를 관리하기 편리하기 때문이다. 예컨대 파일이나 네트워크 소켓 등을 가리키던 unique_ptr가 스코프를 벗어날 때 이러한 리소스를 자동으로 닫는데 활용할 수 있다.

아쉽게도 unique_ptr로 커스텀 제거자를 작성하는 문법은 좀 지저분하다. 작성하는 커스텀 제거자의 타입을 템플릿 타입 매개변수로 지정하기 때문이다. 앞의 예에서 free()의 타입을 알아내기 위해 decltype(free)를 사용했다. 템플릿 타입 매개변수는 반드시 함수에 대한 포인터 타입이어야 한다. 그래서 decltype(free)* 와 같이 *를 더 붙였다. shared_ptr로 커스텀 제거자를 작성하는 문장은 이보다 간단하다.

shared_ptr

shared_ptr의 사용법은 unique_ptr과 비슷하다. shared_ptr은 make_shared()로 생성한다.

auto mySimpleSmartPtr = make_shared<Simple>();

C++ 17부터 shared_ptr도 unique_ptr과 마찬가지로 기존 C 스타일 동적 할당 배열에 대한 포인터를 저장할 수 있다. C++ 17 이전에는 이렇게 할 수 없었는데, 설령 C++ 17에서 지원하더라도 여전히 C 스타일 배열보다는 표준 라이브러리 컨테이너를 사용하는 것이 바람직하다.

shared_ptr도 unique_ptr처럼 get()과 reset()을 제공한다. 다른 점은 reset()을 호출하면 레퍼런스 카운팅 메커니즘에 따라 마지막 shared_ptr가 제거되거나 리셋될 때 리소스가 해제된다. 참고로 shared_ptr은 release()를 지원하지 않는다. 현재 동일한 리소스를 공유하는 shared_ptr의 개수는 use_count()로 알아낼 수 있다.

shared_ptr도 unique_ptr처럼 메모리 할당 및 해제는 new와 delete 연산자를 C++ 17에서 C 스타일 배열을 저장할 때는 new[]나 delete[]를 사용한다. 이러한 동작도 다음과 같이 변경할 수 있다.

shared_ptr<int> myIntSmartPtr(malloc_int(42), free);

여기서 볼 수 있듯이 커스텀 제거자의 타입을 템플릿 타입 매개변수로 지정하지 않아도 된다. 그래서 unique_ptr에 대해 커스텀 제거자를 작성할 때보다 훨씬 간편하다.

다음 코드는 shared_ptr로 파일 포인터를 저장하는 예를 보여준다. shared_ptr가 리셋되면(여기서는 스코프를 벗어나면) CloseFile()이 호출되면서 파일 포인터가 자동으로 닫힌다. 참고로 C++는 파일을 다루는 객체지향 클래스를 별도로 제공한다. 이 클래스도 파일을 자동으로 닫아준다. 지금 보는 예제는 shared_ptr를 메모리가 아닌 다른 리소스에도 사용할 수 있다는 것만 보여주기 위해 기본 C 함수인 fopen()과 fclose()를 사용했다.

void CloseFile(FILE* filePtr)
{
if (filePtr == nullptr)
return;
fclose(filePtr);
cout << "File closed." << endl;
}

int main()
{
FILE* f = fopen("data.txt", "w");
shared_ptr<FILE> filePtr(f, CloseFile);

if (filePtr == nullptr)
{
cerr << "Error opening file." << endl;
}
else
{
cout << "File opened." << endl;
// filePtr을 사용하는 코드
}

return 0;
}

shared_ptr 캐스팅하기

shared_ptr를 캐스팅하는 함수로 const_pointer_cast(), dynamic_pointer_case(), static_pointer_cast()가 제공된다. C++ 17부터는 reinterpret_pointer_cast()도 추가됐다.

레퍼런스 카운팅이 필요한 이유

레퍼런스 카운팅은 어떤 클래스의 인스턴스 수나 현재 사용 중인 특정한 객체를 추적하는 메커니즘이다. 레퍼런스 카운팅을 지원하는 스마트 포인터는 실제 포인터를 참조하는 스마트 포인터 수를 추적한다. 그래서 스마트 포인터가 중복 삭제되는 것을 방지한다.

중복 삭제 문제는 재현하기 쉽다. 앞서 소개한 Simple 클래스 코드는 객체가 생성되거나 삭제될 때 단순히 화면에 메시지를 출력하기만 했다. 다음과 같이 표준 shared_ptr 두 개를 만들고 각각 하나의 Simple 객체를 가리키도록 작성하면 두 포인터가 제거될 때 서로 Simple 객체를 삭제하려 시도한다.

void doubleDelete()
{
Simple* mySimple = new Simple();
shared_ptr<Simple> smartPtr1(mySimple);
shared_ptr<Simple> smartPtr2(mySimple);
}

사용하는 컴파일러에 따라 프로그램이 죽어버릴 수 있는데, 제대로 실행된다면 다음과 같은 결과가 출력 된다.

Simple constructor called!
Simple destructor called!
Simple destructor called!

생성자는 한 번 호출되고 소멸자는 두 번 호출되는데, unique_ptr로 작성해도 똑같은 문제가 발생한다. 레퍼런스 카운팅을 지원하는 shared_ptr 클래스로도 이런 일이 발생해서 의아할 수 있지만 C++ 표준에 따른 정상적인 동작이다.

이렇게 shared_ptr을 앞에 나온 doubleDelete() 함수처럼 객체 하나를 shared_ptr 두 개로 가리키지 말고 다음과 같이 복사본을 만들어서 사용해야 한다.

void noDoubleDelete()
{
auto smartPtr1 = make_shared<Simple>();
shared_ptr<Simple> smartPtr2(smartPtr1);
}

그러면 다음과 같은 결과가 출력 된다.

Simple constructor called!
Simple destructor called!

shared_ptr 두 개가 한 Simple 객체를 동시에 가리키더라도 Simple 객체는 딱 한 번만 삭제된다. 참고로 unique_ptr는 레퍼런스 카운팅을 지원하지 않는다. 정확히 말하면 unique_ptr은 원래 복제 생성자를 지원하지 않기 때문에 noDoubleDelete() 함수처럼 사용할 수 없다.

앞에서 본 doubleDelete() 함수처럼 코드를 작성하려면 중복 삭제를 방지하는 스마트 포인터를 직접구현해야 한다. 하지만 이때도 역시 표준 smart_ptr 템플릿으로 리소스를 공유하는 것이 바람직하다. 가능하면 doubleDelete() 함수처럼 작성하지 말고 복제 생성자를 사용한다.

앨리어싱

shared_ptr은 앨리어싱(aliasing)을 지원한다. 그래서 한 포인터(소유한 포인터, owned pointer)를 다른 shared_ptr와 공유하면서 다른 객체(저장된 포인터, stored pointer)를 가리킬 수 있다. 예컨대 shared_ptr가 객체를 가리키는 동시에 그 객체의 멤버도 가리키게 할 수 있다. 코드로 표현하면 다음과 같다.

class Foo
{
public:
Foo(int value) : mData(value) {}
int mData;
}

auto foo = make_shared<Foo>(42);
auto aliasing = shared_ptr<int>(foo, &foo->mData);

여기서 두 shared_ptr(foo와 aliasing)가 모두 삭제될 때만 Foo 객체가 삭제된다.

소유한 포인터는 레퍼런스 카운팅에 사용하는 반면, 저장된 포인터는 포인터를 역참조 하거나 그 포인터에 대해 get()을 호출할 때 리턴된다. 저장된 포인터는 비교 연산을 비롯한 대부분의 연산에 적용할 수 있다. 

이렇게 하지 않고 owner_before() 메서드나 std::owner_less 클래스를 사용하여 소유한 포인터에 대해 비교 연산을 수행해도 된다. 이러한 기능은 shared_ptr를 std::set에 저장할 때와 같이 특정한 상황에 유용하다.

weak_ptr

shared_ptr과 관련하여 C++에서 제공하는 또 다른 클래스로 weak_ptr가 있다. weak_ptr는 shared_ptr가 가리키는 리소스의 레퍼런스를 관리하는데 사용된다. weak_prt은 리소스를 직접 소유하지 않기 때문에 shared_ptr가 해당 리소스를 해제하는데 아무런 영향을 미치지 않는다. weak_ptr은 삭제될 때 (예컨대 스코프를 벗어날 때) 가리키던 리소스를 삭제하지 않고, shared_ptr가 그 리소스를 해제 했는지 알아낼 수 있다.

weak_ptr의 생성자는 shared_ptr나 다른 weak_ptr을 인수로 받는다. weak_ptr에 저장된 포인터에 접근하려면 shared_ptr로 변환해야 한다. 변환 방법은 다음 두 가지가 있다.

  • weak_ptr 인스턴스의 lock() 메서드를 이용하여 shared_ptr를 리턴받는다. 이때 shared_ptr에 연결된 weak_ptr가 해제되면 shared_ptr의 값은 nullptr가 된다.
  • shared_ptr의 생성자에 weak_ptr를 인수로 전달해서 shared_ptr을 새로 생성한다. 이때 shared_ptr에 연결된 weak_ptr가 해제되면 std::bad_weak_ptr 익셉션이 발생한다.

weak_ptr을 사용하는 예는 다음과 같다.

void useResource(weak_ptr<Simple>& weakSimple)
{
auto resource = weakSimple.lock();
if (resource)
{
cout << "Resource still alive." << endl;
}
else
{
cout << "Resource has been freed!" << endl;
}
}

int main()
{
auto sharedSimple = make_shared<Simple>();
weak_ptr<Simple> weakSimple(sharedSimple);

// weak_ptr을 사용한다.
useResource(weakSimple);

// shared_ptr을 리셋한다.
// Simple 리소스에 대한 shared_ptr을 하나뿐이므로 weak_ptr가 살아 있더라도 리소스가 해제된다.
sharedSimple.reset();

// weak_ptr을 한 번 더 사용한다.
useResource(weakSimple);

return 0;
}

이 코드를 실행한 결과는 다음과 같다.

Simple constructor called!
Resource still alive.
Simple destructor called!
Resource has been freed!

이동 의미론

표준 스마트 포인터인 shared_ptr, unique_ptr, weak_ptr은 모두 성능 향상을 위해 이동 의미론을 지원한다. 이동 의미론은 9장에서 자세히 소개하고 여기서는 이동 의미론을 이용하면 함수에서 스마트 포인터를 리턴하는 과정을 굉장히 효율적으로 처리할 수 있다는 점만 알아두자. 예컨대 다음과 같이 crate() 함수를 작성해서 main() 함수에서 호출할 수 있다.

unique_ptr<Simple> create()
{
auto ptr = make_unique<Simple>();
// ptr을 사용하는 코드를 작성한다.
return ptr;
}

int main()
{
unique_ptr<Simple> mySmartPtr1 = create();
auto mySmartPtr2 = create();
return 0;
}

enable_shared_from_this

믹스인 클래스인 std::enable_shared_from_this를 이용하면 객체의 메서드에서 shared_ptr나 weak_ptr을 안전하게 리턴할 수 있다. 믹스인 클래스는 28장에서 자세히 설명한다. enable_shared_from_this 믹스인 클래스는 다음 두 개의 메서드를 클래스에 제공한다.

  • shared_from_this(): 객체의 소유권을 공유하는 shared_ptr을 리턴한다.
  • weak_from_this(): 객체의 소유권을 추적하는 weak_ptr를 리턴한다.

이는 고급 기능으로 간단히 사용 방법을 소개하면 다음과 같다.

class Foo : public enable_shared_from_this<Foo>
{
public:
shared_ptr<Foo> getPointer()
{
return shared_from_this();
}
}

int main()
{
auto ptr1 = make_shared<Foo>();
auto ptr2 = ptr1->getPointer();
}

여기서 객체의 포인터가 shared_ptr에 이미 저장된 상태에서만 객체에 shared_from_this()를 사용할 수 있다는 점에 주의해야 한다. 예제의 main()을 보면 make_shared()로 Foo 인스턴스를 담은 shared_ptr인 ptr1을 생성했다. 그리고 나서야 Foo 인스턴스에 대한 shared_from_this()를 호출할 수 있다.

getPointer() 메서드를 다음과 같이 구현하면 안된다.

class Foo
{
public:
shared_ptr<Foo> getPointer()
{
return shared_ptr<Foo>(this);
}
}

Foo 클래스를 이렇게 구현한 상태에서 앞에 나온 main() 코드처럼 작성하면 중복 삭제가 발생한다. 두 개의 shared_ptr(ptr1과 ptr2)가 동일한 객체를 가리키고 있어서 스코프를 벗어나면 서로 이 객체를 삭제하려 하기 때문이다.

현재는 폐기된 auto_ptr

C++ 11 이전에는 표준 라이브러리에서 스마트 포인터를 간단히 구현한 auto_ptr을 제공했는데 몇 가지 심각한 단점이 있었다. 그중 하나는 vector와 같은 표준 라이브러리 컨테이너 안에서 제대로 작동하지 않았다는 점이다.

C++ 11과 C++ 14부터는 auto_ptr를 공식적으로 지원하지 않는다고 선언했고, C++ 17에서 완전히 삭제되면서 그 빈자리를 unique_ptr과 shared_ptr이 대체했다. 여기서 auto_ptr을 소개하는 이유는 절대 사용하면 안 된다는 것을 강조하기 위해서다.

흔히 발생하는 메모리 문제

스트링 과소 할당 문제

C 스타일 스트링에서 가장 흔히 발생하는 문제는 과소 할당(underallocation)이다. 이 문제는 주로 프로그래머가 스트링의 끝을 나타내는 널문자(‘\0’)가 들어갈 공간을 빼먹고 공간을 할당할 때 발생한다. 또한 프로그래머가 스트링의 최대 크기를 특정한 값으로 미리 정해둘 때도 발생한다. C 스타일 스트링 함수는 크기에 제한을 두지 않기 때문에 스트링에 할다오딘 메모리 공간을 얼마든지 넘어갈 수 있다.

(예시 생략)

메모리 경계 침범

포인터는 단지 메모리 주소일 뿐이어서 메모리에서 아무 곳이나 가리킬 수 있다. 실제로 이렇게 아무 곳이나 가리키는 상황이 종종 발생한다. 

만약 어떤 이유로 스트링의 끝읖 료현하는 ‘\0’ 문자가 사라졌다고 하자. 이때 스트링의 모든 문자를 ‘m’으로 바꾸는 함수를 호출하면 로프의 종료 조건을 만족하지 못하기 때문에 스트링에 할당된 공간을 지나서도 계속해서 ‘m’으로 채운다.

void fillWithM(char* inStr)
{
int i = 0;
while (inStr[i] != '\0')
{
inStr[i] = 'm';
i++;
}
}

이 함수에 종료 문자가 잘못된 스트링을 입력하면 결국 메모리에서 중요한 영역까지 덮어써서 프로그램이 뻗어버린다. 프로그램에서 객체에 관련된 메모리 영역이 갑자기 ‘m’으로 채워지면 분명 좋지 않은 상황이 펼쳐질 것이다.

이렇나 문제가 스트링이 아닌 배열에 발생하는 것을 흔히 버퍼 오버플로 에러(buffer overflow error)라 부른다. 지금까지 알려진 악명 높은 바이러스나 웜 중 상당수는 이 버그를 악용해서 경계를 벗어난 메모리 영역을 덮어쓰는 방식으로 현재 구동 중인 프로그램에 악의적인 코드를 주입한 것이다.

현재 나와 있는 메모리 검사 도구는 버퍼 오버플로 문제를 찾아준다. 또한 string이나 vector와 같은 C++ 고급 기능을 활용하면 C 스타일 스트링이나 배열을 사용할 때 흔히 발생하던 여러 버그를 방지할 수 있다.

메모리 누수

메모리 누수 문제는 C/C++ 프로그래밍 과정에서 발견하거나 해결하기 가장 힘든 작업으로 손꼽힌다. 원하는 결과를 내도록 힘들여 만든 프로그램이 실행될수록 메모리 공간을 잡아먹는다면 메모리 누수 현상이 발생한 것이다. 이럴 때는 가장 먼저 스마트 포인터를 도입하는 것이 좋다.

(이하 예시 및 관련 도구 설명 생략. MS의 비주얼 C++을 사용한다면 디버그 라이브러리에서 기본으로 제공하는 메모리 누수 감지 기능을 사용할 수 있고, 리눅스라면 밸그라인드라는 오픈소스 도구를 사용할 수 있다.)

중복 삭제와 잘못된 포인터

delete로 포인터에 할당된 메모리를 해제하면 그 메모리를 프로그램의 다른 부분에서 사용할 수 있다. 하지만 그 포인터를 계속 쓰는 것을 막을 수는 없다. 이를 댕글링 포인터(dangling pointer)라 부른다. 이때 중복 삭제하면 문제가 발생하는데, 한 포인터에 delete를 두 번 적용하면 이미 다른 객체를 할당한 메모리를 해제해 버리기 때문이다.

중복 삭제 문제와 해제한 메모리를 다시 사용하는 문제를 사전에 찾아내기란 굉장히 힘들다. 짧은 시간 동안 메모리를 삭제하는 연산이 두 번 실행되면 그 사이에 같은 메모리를 재사용할 가능성이 적기 때문에 프로그램이 계속해서 정상ㅈ거으로 실행될 수 있다. 마찬가지로 객체를 삭제한 직후 곧바로 다시 사용하더라도 그 영역이 삭제 전 상태로 계속 남아 있을 가능성이 많기 때문에 문제가 생기지 않을 수 있다.

그렇다 하더라도 문제가 발생하지 않는다고 장담할 수는 없다. 메모리를 할당할 때 삭제된 객체를 보존하지 않기 때문이다. 설령 제대로 작동하더라도 삭제된 객체를 이용하는 것은 바람직한 코드 작성 방식이 아니다.

MS의 비주얼 C++이나 밸그라인드 같은 도구가 해제된 객체를 계속 사용하는 문제를 감지하는 기능도 제공한다.

스마트 포인터를 사용하라는 충고를 무시하고 계속해서 일반 포인터를 사용하려면 메모리르 해제한 후 포인터 값을 nullptr로 초기화하는 작업만이라도 반드시 기억하기 바란다. 그러면 실수로 같은 포인터를 두 번 삭제하거나 해제한 포인터를 계속 사용하는 문제를 막을 수 있다. 참고로 nullptr로 설정된 포인터에 대해 delete를 호출해도 문제가 발생하지 않는다. 그저 아무 일도 하지 않을 뿐이다.

이상엽/ 위상수학/ 분리공리

T_{0} , T_{1} , T_{2} 공간

정의

  • 대표적인 위상적 불변량인 분리공리들을 알아본다.
    • 모든 위상공간은 밀착위상공간에서부터 이산위상공간 사이에  스펙트럼처럼 존재한다.
    • 밀착위상공간의 열린집합은 공집합이거나 자기 자신만 가능

Def 1. [T_{0} ]

(X, \mathfrak{I}) 가 위상공간이라 하자.

\forall x, y \in X, \exists U \in \mathfrak{I} : (x \in U \wedge y \notin U) \vee (x \notin U \wedge y \in U)

을 만족하면 X T_{0} 라 한다. (단 x \neq y )

  • T_{0} 를 도식화 하면 다음과 같다.

  • T_{0} 공간은 밀착위상 공간의 바로 다음 등급

Def 2. [T_{1} ]

(X, \mathfrak{I}) 가 위상공간이라 하자.

\forall x, y \in X, \exists U, V \in \mathfrak{I} : (x \in U \wedge y \notin U) \wedge (x \notin V \wedge y \in V)

을 만족하면 X T_{1} 라 한다. (단 x \neq y )

  • T_{1} 를 도식화 하면 다음과 같다.

Def 3. [T_{2} (하우스도르프)]

(X, \mathfrak{I}) 가 위상공간이라 하자.

\forall x, y \in X, \exists U, V \in \mathfrak{I} : x \in U \wedge y \in V \wedge U \cap V = \emptyset

을 만족하면 X T_{2} 라 한다. (단 x \neq y )

  • T_{2} 를 도식화 하면 다음과 같다.

  • T_{0}, T_{1} , 하우스도르프(T_{2} )인 위상공간을 각각 간단히 T_{0} -공간, T_{1} -공간, 하우스도르프공간(T_{2} -공간)이라 한다.
  • 정의에 의해 자명하게 다음이 성립한다.
    • 하우스도르프공간 \Rightarrow T_{1} -공간 \Rightarrow T_{0} -공간

ex1) 임의의 집합 X 에 대한 밀착위상공간은 T_{0} -공간이 아니다.

ex2) 집합 X = \{ 1, 2, 3 \} 위의 위상 \mathfrak{I} = \{ \emptyset, X, \{1\}, \{2\}, \{1, 2\} \} 에 대하여 위상공간 (X, \mathfrak{I}) T_{0} -공간이지만 T_{1} -공간은 아니다.

ex3) 무한집합 X 에 대한 유한여집합위상 공간은 T_{1} -공간이지만 하우스도르프 공간은 아니다.

ex4) 모든 거리공간은 하우스도르프 공간이다.

  • 하우스도르프 공간부터 위상수학의 의미있는 논의가 가능해짐.
  • 최초에 하우스도르프가 위상공간을 정의했을 때 사용했던 공간.
  • T_{0}, T_{1} 공간은 값이 하나의 값으로 수렴한다는 것이 보장이 안되지만, T_{2} 공간은 수렴 값이 하나가 보장 됨.

여러 가지 정리

  • 분리공리와 관련한 몇 가지 중요한 정리들을 알아보자.

Thm 1. [위상적불변량]

두 위상 공간 X, Y 가 위상동형이면 다음이 성립한다.

  1. X T_{0} 이다. \Leftrightarrow Y T_{0} 이다.
  2. X T_{1} 이다. \Leftrightarrow Y T_{1} 이다.
  3. X T_{2} 이다. \Leftrightarrow Y T_{2} 이다.

Thm  2. [부분공간]

  1. T_{0} -공간의 부분공간은 T_{0} 이다.
  2. T_{1} -공간의 부분공간은 T_{1} 이다.
  3. T_{2} -공간의 부분공간은 T_{2} 이다.

Thm 3. [곱공간]

  1. 모든 X_{\alpha} T_{0} -공간이면 \Pi_{\alpha \in \Lambda} X_{\alpha} T_{0} 이다.
  2. 모든 X_{\alpha} T_{1} -공간이면 \Pi_{\alpha \in \Lambda} X_{\alpha} T_{1} 이다.
  3. 모든 X_{\alpha} T_{2} -공간이면 \Pi_{\alpha \in \Lambda} X_{\alpha} T_{2} 이다.

Thm 4. [T_{0} -공간의 성질]

위상공간 X 에 대하여 다음은 동치이다.

  1. X T_{0} 이다.
  2. \forall x, y \in X, \overline{\{x\}} \neq \overline{\{y\}} (단, x \neq y )
    • \overline{\{x\}} 는 x 의 폐포(closure)

Thm 5. [T_{1} -공간의 성질]

위상공간 (X, \mathfrak{I}) 에 대하여 다음 두 명제는 동치이다.

  1. X T_{1} 이다.
  2. \forall x \in X, X - \{ x \} \in \mathfrak{I}
    • X - \{ x \} 은  \{x\}^{c} 이라는 뜻
    • \{ x \} 는 닫힌집합. T_{1} 공간이면 1점 집합은 닫힌집합이 된다.
    • T_{1} 은 상당히 상위 클래스이기 때문에 이하 대부분의 공간이 이 성질을 물려 받는다.

Def. [위상공간상의 수렴]

다음을 만족하면 위상공간 (X, \mathfrak{I}) 상의 수열 \{ x_{n} \} 이 점 x \in X 로 수렴한다고 한다.

\forall U \in \mathfrak{I}, x \in U, \exists N \in \mathbb{N} : n \geq N \Rightarrow x_{n} \in U

Thm 6. [하우스도르프공간의 성질]

하우스도르프공간상의 수렴하는 수열은 유일한 극한을 갖는다.

ex) X = \{ 1, 2, 3 \}, \mathfrak{I} = \{ \emptyset, X, \{ 1, 3 \} \} 일 때 다음 수열은 여러 극한을 갖는다.

\{ x_{n} \} : 1, 3, 1, 3, 1, 3 ...

T_{3} , T_{4} 공간

정의

  • 점의 분리성에서 더 나아가 점을 포함한 집합의 분리성을 등급화한다.
  • 점을 포함하는 최소 크기의 집합(한점집합)은 1.(2).Thm 5.에 의해 T_{1} -공간에서 닫힌집합임을 기억하자.

Def 1. [T_{3} (정칙)]

T_{1} -공간인 (X, \mathfrak{I}) 의 임의의 닫힌집합 C 와 점 x \in X - C 에 대하여 \exists U, V \in \mathfrak{I} : C \subset U \wedge x \in V \wedge U \cap V = \emptyset 를 만족하면 X T_{3} 라 한다.

  • T_{3} 를 도식화하면 다음과 같다.

Def 2. [T_{4} (정규)]

T_{1} -공간인 (X, \mathfrak{I}) 의 임의의 서로소인 닫힌집합 C, D 에 대하여 \exists U, V \in \mathfrak{I} : C \subset U \wedge D \subset V \wedge U \cap V = \emptyset 를 만족하면 X T_{4} 라 한다.

  • T_{4} 를 도식화하면 다음과 같다.

  • 정칙(T_{3} ), 정규(T_{4} )인 위상공간을 각각 가단히 정칙공간, 정규공간이라 한다.
  • 위상공간 (X, \mathfrak{I}) T_{1} 이라는 조건을 놓치지 않도록 유의하자.

ex1) 집합 X = \{ 1, 2, 3 \} 에 위상 \mathfrak{I} = \{ \emptyset, X, \{1\}, \{2, 3\} \} 을 준 위상공간 (정칙 공간이지만 T_{1} 은 아닌 예. 고로 이것은 T_{3} 가 아니다.)

ex2) 집합 X = \{ 1, 2, 3 \} 에 위상 \mathfrak{I} = \{ \emptyset, X, \{1\}, \{2\}, \{1, 2\} \} 을 준 위상공간 (정규 공간이지만 T_{1} 은 아닌 예. 고로 이것은 T_{4} 가 아니다.)

여러 가지 정리

Thm 1. [T_{3} -공간 \Rightarrow T_{2}-공간]

모든 정칙공간은 하우스도르프이다.

Thm 2. [T_{4} -공간 \Rightarrow T_{3}-공간]

모든 정규공간은 정칙이다.

Thm 3. [거리공간 \Rightarrow T_{4}-공간]

모든 거리공간은 정규이다.

참고) 위상공간들 사이의 관계

Lemma 1. [전단사 사상의 성질]

두 위상공간 X, Y 사이의 전단사사상 f : X \to Y 가 열린사상이면 f 는 닫힌사상이기도 하다.

Thm 4. [위상적 불변량]

두 위상공간 X, Y 가 위상동형이면 다음이 성립한다.

  1. X 는 정칙공간 \Leftrightarrow Y 는 정칙공간
  2. X 는 정규공간 \Leftrightarrow Y 는 정규공간

Lemma 2. [부분공간의 닫힌집합]

위상공간 X 의 부분공간 A 에 대하여 다음 두 명제는 동치이다.

  1. C \subset A A 의 닫힌집합이다.
  2. C = A \cup D 를 만족하는 X 의 닫힌집합 D 가 존재한다.

Lemma 3. [닫힌부분공간의 성질]

위상공간 X B \subset A \subset X 에 대하여 B 가 부분공간 A 의 닫힌집합이고 A X 의 닫힌집합이면 B X 의 닫힌집합이다.

Thm 5. [부분공간]

  1. 정칙공간의 부분공간은 정칙이다.
  2. 정규공간의 닫힌부분공간은 정규이다.
  • 정규공간의 부분공간이 항상 정규가 되는 것은 아니다.

Lemma 4. [T_{3} -공간의 또 다른 정의]

다음 조건은 T_{1} -공간 (X, \mathfrak{I}) 가 정칙공간이기 위한 필요충분조건이다.

\forall x \in X, x \in \forall U \in \mathfrak{I}, \exists V \in \mathfrak{I} : x \in V \subset \overline{V} \subset U

Thm 6. [정칙공간들의 곱공간]

정칙공간들의 곱공간은 정칙이다.

  • 정규공간들의 곱공간이 항상 정규가 되는 것은 아니다.

전문가를 위한 C++/ 스트링과 스트링 뷰 다루기

(전체가 아니라 C#과 차이가 있는 부분을 중심으로 요약 정리)

동적 스트링

스트링을 주요 객체로 제공하는 프로그래밍 언어를 보면 스트링 크기를 임의로 확장하거나 서브스트링을 추출하거나 교체하는 것처럼 고급기능을 제공하지만 C와 같은 언어는 스트링을 부가 기능처럼 취급한다. 그래서 스트링을 언어의 정식 데이터 타입으로 제공하지 않고 단순히 고정된 크기의 바이트 배열로 처리했다.

C 스타일 스트링

C 언어는 스트링을 문자 배열로 표현했다. 스트링의 마지막에 널 문자(\0)를 붙여서 스트링이 끝났음을 표현했다. 이러한 널 문자에 대한 공식 기호는 NUL 이다. 여기서 L이 두 개가 아니며 NULL 포인터와는 다른 값이다.

(이하 C의 스트링 내용 생략. 스트링을 배열로 다루는데다가 마지막에 \0을 추가하기 위해 배열의 길이를 +1 더 써야 한다는데, C를 쓰는 사람이 아니라 요즘 프로그래밍 환경을 쓰는 사람이라면 굳이 살펴보지 않아도 될 듯)

Caution) 마이크로소프트 비주얼 스튜디오에서 C 스타일 스트링 함수를 사용하면 컴파일러에서 보안 관련 경고나 이 함수가 폐기됐다는 에러 메시지가 출력될 수 있다.

스트링 리터럴

cout << "hello" << endl;

위 예시와 같이 ‘hello’ 처럼 변수에 담지 않고 곧바로 값을 표현한 스트링을 스트링 리터럴이라 부른다. 스트링 리터럴은 내부적으로 읽기 전용 영역에 저장된다. 그래서 컴파일러는 스트링 리터럴이 코드에 여러 번 나오면 그중 한 스트링에 대한 레퍼런스를 재사용하는 방식으로 메모리를 절약한다.

다시 말해 코드에서 ‘hello’란 스트링 리터럴을 500번 넘게 사용해도 컴파일러는 hello에 대한 메모리 공간을 딱 하나만 할당하는데, 이를 리터럴 풀링(literal pooling)이라 한다.

스트링 리터럴을 변수에 대입할 수 있지만 메모리의 읽기 전용 영역에 있게 되거나 동일한 리터럴을 여러 곳에서 공유할 수 있기 때문에 변수에 저장하면 위험하다.

C++ 표준에서는 스트링 리터럴을 ‘const char가 n개인 배열’ 타입으로 정의하고 있다. 하지만 const가 없던 시절에 작성된 레거시 코드의 하위 호환성을 보장하도록 스트링 리터럴을 const char*가 아닌 타입으로 저장하는 컴파일러도 많다.

const 없이 char* 타입 변수에 스트링 리터럴을 대입하더라도 그 값을 변경하지 않는 한 프로그램 실행에는 문제가 없다. 스트링 리터럴을 수정하는 동작에 대해서는 명확히 정의돼 있지 않다. 따라서 프로그램이 갑자기 죽을 수도 있고, 실행은 되지만 겉으로 드러나지 않는 효과가 발생할 수도 있고, 수정 작업을 그냥 무시할 수도 있다. 예컨대 다음과 같이 코드를 작성하면 결과를 예측할 수 없다.

char* ptr = "hello";  // 변수에 스트링 리터럴을 대입한다.
ptr[1] = 'a'; // 결과를 예측할 수 없다.

스트링 리터럴을 참조할 때는 const 문자에 대한 포인터를 사용하는 것이 훨씬 안전하다. 다음 코드도 위와 똑같은 버그를 담고 있지만, 스트링 리터럴을 const char* 타입 변수에 대입했기 때문에 컴파일러가 걸러낼 수 있다.

const char* ptr = "hello";  // 변수에 스트링 리터럴을 대입한다.
ptr[1] = 'a'; // 읽기 전용 메모리에 값을 쓰기 때문에 에러가 발생한다.

문자 배열(char[])의 초깃값을 설정할 때도 스트링 리터럴을 사용한다. 이때 컴파일러는 주어진 스트링을 충분히 담을 정도로 큰 배열을 생성한 뒤 여기에 실제 스트링값을 복사한다. 컴파일러는 이렇게 만든 스트링 리터럴을 읽기 전용 메모리에 넣지 않으며 재사용하지도 않는다.

char arr[] = "hello";  // 컴파일러는 적절한 크기의 문자 배열 arr을 생성한다.
arr[1] = 'a'; // 이제 스트링을 수정할 수 있다.

로 스트링 리터럴

로 스트링 리터럴(raw string literal)이란 여러 줄에 걸쳐 작성한 스트링 리터럴로서 그 안에 담긴 인용 부호를 이스케이프 시퀀스로 표현할 필요가 없고, \t나 \n 같은 이스케이프 시퀀스를 일반 텍스트로 취급한다.

예컨대 일반 스트링 리터럴을 다음과 같이 작성하면 컴파일 에러가 발생한다.

const char* str = "Hello "World"!";  // 에러 발생

이때는 큰 따옴표를 다음과 같이 이스케이프 시퀀스로 표현한다.

const char* str = "Hello \"World\"!";

하지만 로 스트링 리터럴을 사용하면 인용부호를 이스케이프 시퀀스로 표현하지 않아도 된다. 로 스트링 리터럴을 R”(로 시작해서 )”로 끝난다.

const char* str = R"("Hello "World"!)";

로 스트링 리터럴을 사용하지 않고 여러 줄에 걸친 스트링을 표현하려면 줄이 바뀌는 지점에 \n을 넣어야 한다.

const char* str = "Line 1\nLine 2";

로 스트링 리터럴로 표현할 때는 그냥 엔터키를 누르면 된다. (이거는 오히려 더 불편한 듯)

const char* str = R"(Line 1
Line 2)";

로 스트링 리터럴에서는 이스케이프 시퀀스를 무시하기 때문에 다음과 같이 로 스트링 리터럴을 작성하면 \t 이스케이프 시퀀스가 탭 문자로 바뀌지 않고 백슬래시 뒤에 t라는 문자가 나온 것으로 표현된다.

const char* str = R"(Is the following a tab character? \t)";

로 스트링 리터럴은 )”로 끝나기 때문에 그 안에 )”를 넣을 수 없다. 예컨대 다음과 같이 중간에 )”가 들어가면 에러가 발생한다.

const char* str = R"(Embedded )" characters)";  // 에러 발생

)” 문자를 추가하려면 다음과 같이 확장 로 스트링 리터럴(extended raw string literal) 구문으로 표현해야 한다.

R"d-char-sequence(r-char-sequence)d-char-sequence"

여기서 r-char-sequence에 해당한느 부분이 실제 로 스트링이다. d-char-sequence라고 표현한 부분은 구분자 시퀀스로서 반드시 로 스트링 리터럴의 시작과 끝에 똑같이 나와야 한다. 이 구분자 시퀀스는 최대 16개의 문자를 가질 수 있다. 이때 구분자 시퀀스는 로 스트링 리터럴 안에 나오지 않는 값으로 지정해야 한다.

const char* str = R"-(Embedded )" characters)-"; 

C++ std::string 클래스

C++ 표준 라이브러리는 스트링을 좀 더 잘 표현하도록 std::string 클래스를 제공한다. 엄밀히 말해 std::string은 basic_string이라는 클래스 템플릿의 인스턴스로서 <cstring>의 함수와 기능은 비슷하지만 메모리 할당 작업을 처리해주는 기능이 더 들어 있다.

C 스타일 스트링의 문제점

장점

  • 간단하다. 내부적으로 기본 문자 타입과 배열 구조체로 처리한다.
  • 가볍다. 제대로 사용하면 메모리를 꼭 필요한 만큼만 사용한다.
  • 로우 레벨이다. 따라서 메모리의 실제 상태를 조작하거나 복사하기 쉽다.
  • C 프로그래머에게 익숙하다.

단점

  • 스트링 데이터 타입에 대한 고차원 기능을 구현하려면 상당한 노력이 필요하다.
  • 찾기 힘든 메모리 버그가 발생하기 쉽다.
  • C++의 객체지향적인 특성을 제대로 활용하지 못한다.
  • 프로그래머가 내부 표현 방식을 이해해야 한다.

C++의 string은 C 스타일의 스트링이 가진 장점은 유지하면서 단점을 해결해 준다.

string 클래스 사용법

string은 실제로는 클래스지만 마치 기본 타입인 것처럼 사용한다. 그래서 코드를 작성할 때는 기본 타입처럼 취급하면 된다. C++ string에 연산자 오버로딩을 적용하면 C 스타일 스트링보다 훨씬 사용하기 편하다.

(+, += 연산 예시 생략)

C 스타일 스트링은 == 연산자로 비교할 수 없다는 단점이 있다. C 언어에서 스트링을 비교하려면 다음과 같이 작성한다.

if (strcmp(a, b) == 0)

또한 C 스트링은 <, <=, >=, >로 비교할 수 없기 때문에 주어진 스트링을 사전식 나열 순서에 따라 비교해서 -1, 0, 1을 리턴하는 strcmp()를 사용했다. 따라서 코드가 지저분하고 읽기 힘들 뿐 아니라 에러가 발생하기 쉽다.

C++에서 제공하는 string에서는 ==, !=, < 와 같은 연산자를 오버로딩해서 스트링에 적용할 수 있다. 물론 C 처럼 각각의 문자를 []로 접근할 수도 있다.

다음 코드를 보면 연산자 오버로딩으로 string을 확장해도 메모리 관련 작업은 string 클래스가 알아서 처리해준다는 것을 알 수 있다. 따라서 (허용된 범위를 벗어나는) 메모리 오버런(memory overrun)이 발생할 걱정을 할 필요 없다.

string myString = "hello";
myString += ", there";
string myOtherString = myString;

if (mystring == myOtherString)
{
myOtherString[0] = 'H';
}

cout << myString << endl;
cout << myOtherString << endl;

위 코드의 결과는 다음과 같다.

hello, there
Hello, there

위 예제에서 몇 가지 짚고 넘어갈 점이 있다. 첫째, 스트링을 할당하거나 크기를 조절하는 코드가 여러 군데 흩어져 있어도 메모리 누수가 발생하지 않는다. string 객체는 모두 스택 변수로 생성되기 때문이다. string 클래스를 사용하면 메모리를 할당하거나 크기를 조절할 일이 상당히 많긴 하지만 string 객체가 스코프를 벗어나자마자 여기에 할당된 메모리를 string 소멸자가 모두 정리한다.

둘째, 연산자를 원하는 방식으로 작동하게 할 수 있다. 예컨대 = 연산자를 스트링을 복사하는데 사용하면 상당히 편하다. 배열 방식으로 스트링을 다루는데 익숙하다면 이 기능이 굉장히 편하긴 하지만 좀 헷갈릴 수 있다. 그래도 걱정할 필요 없다. 일단 string 클래스의 처리 방식이 좋다는 것을 깨닫는 순간 금세 적응하게 된다.

string 클래스에서 제공하는 c_str() 메서드를 사용하면 C 언어에 대한 호환성을 보장할 수 있다. 이 메서드는 C 스타일 스트링을 표현하는 const 문자 포인터를 리턴한다. 하지만 string에 대한 메모리를 다시 할당하거나 해당 string 객체를 제거하면 이 메서드가 리턴한 const 포인터를 더 사용할 수 없게 된다. 따라서 현재 string에 담긴 내용을 정확시 하용하려면 이 메서드를 호출한 직후에 리턴된 포인터를 활용하도록 코드를 작성하는 것이 좋다.

또한 함수 안에 생성된 스택 기반 string 객체에 대해서는 c_str() 을 호출한 결과를 절대로 리턴 값으로 전달하면 안 된다.

또한 string에서 제공하는 data() 메서드는 C++ 14까지만 해도 c_str() 처럼 const char* 타입으로 값을 리턴했다. 그러나 C++ 17부터는 non-const 스트링에 대해 호출하면 char*를 리턴하도록 변경됐다.

std::string 리터럴

소스 코드에 나온 스트링 리터럴은 주로 const char*로 처리한다. 표준 사용자 정의 리터럴 ‘s’를 사용하면 스트링 리터럴을 std::string으로 만들 수 있다.

auto string1 = "Hello World";  // strin1의 타입은 const char*이다.
auto string2 = "Hello World"; // string2의 타입은 std::string이다.

하이레벨 숫자 변환

숫자를 string으로 변환하는 함수는 to_string이다.

(예시 생략)

string을 숫자로 변환하는 함수는 다음과 같아. 이 함수의 프로토타입에서 str은 변환하려는 원본 string 값을 의미하고, idx는 아직 변환되지 않은 부분의 맨 앞에 있는 문자의 인덱스를 가리키는 포인터고, base는 변환할 수의 밑(기수, 기저)이다. idx 포인터를 널 포인터로 지정하면 이 값을 무시한다.

여기 나온 변환 함수들은 제일 앞에 나온 공백 문자를 무시하고, 변환에 실패하면 invalid_argument 익셉션을 던지고 변환된 값이 리턴 타입의 범위를 벗어나면 out_of_range 익셉션을 던진다.

int stoi (const string& str, size_t *idx=0, int base=10);
long stol (const string& str, size_t *idx=0, int base=10);
unsigned long stoul (const string& str, size_t *idx=0, int base=10);
long long stoll (const string& str, size_t *idx=0, int base=10);
unsigned long long stoull (const string& str, size_t *idx=0, int base=10);
float stof (const string& str, size_t *idx=0);
double stod (const string& str, size_t *idx=0);
long double stold (const string& str, size_t *idx=0);

예컨대 다음과 같다.

const string toParse = " 123USD";
size_t index = 0;
int value = stoi(toParse, &index);
cout << "Parsed value: " << value << endl;
cout << "First non-parsed character: '" << toParse[index] << "'" << endl;

실행 결과는 다음과 같다.

Parsed value: 123
First non-parsed character: 'U'

로우 레벨 숫자 변환

C++ 17부터 로우 레벨 숫자 변환에 대한 함수도 다양하게 제공된다. 이 함수는 <charconv> 헤더에 정의돼 있다. 이 함수는 메모리 할당에 관련된 작업은 전혀 해주지 않기 때문에 호출한 측에서 버퍼를 할당하는 방식으로 사용해야 한다.

또한 고성능과 로케일 독립성(locale-independent)에 튜닝됐다. 그래서 다른 하이 레벨 숫자 변환 함수에 비해 처리 속도가 엄청나게 빠르다. 숫자 데이터와 사람이 읽기 좋은 포맷(JSON, XML 등) 사이의 변환 작업을 로케일에 독립적이면서 빠른 속도로 처리하고 싶다면 이러한 로우 레벨 함수를 사용한다.

정수를 문자로 변환하려면 다음과 같은 함수를 사용한다.

to_chars_result to_chars(char* first, char* last, IntegerT value, int base= 10);

여기서 IntegerT 자리에 부호 있는 정수나 부호 없는 정수 또는 char 타입이 나올 수 있다. 결과는 to_chars_result 타입으로 리턴되며 다음과 같이 정의돼 있다.

struct to_chars_results
{
char* ptr;
errc ec;
}

정상적으로 변환됐다면 ptr 멤버는 끝에서 두 번째 문자를 가리키고, 그렇지 않으면 last 값과 같다. (이때 ec == errc::value_too_large다)

예컨대 다음과 같다.

std::string out(10, ' ');
auto result = std::to_chars(out.data(), out.data() + out.size(), 12345);
if (result.ec == std::errc()) { /* 제대로 변환된 경우 */ }

1장에서 소개한 C++ 17의 구조적 바인딩을 적용하면 다음과 같이 표현할 수 있다.

std::string out(10, ' ');
auto [ptr, ec] = std::to_chars(out.data(), out.data() + out.size(), 12345);
if (ec == std::errc()) { /* 제대로 변환된 경우 */ }

다음과 같이 부동소수점 타입에 대한 변환 함수도 제공한다.

to_chars_result to_chars(char* first, char* last, FloatT value);
to_chars_result to_chars(char* first, char* last, FloatT value, chars_format format);
to_chars_result to_chars(char* first, char* last, FloatT value, chars_format, int precision);

여기서 FloatT 자리에 float, double, long double이 나올 수 있다. 구체적인 포맷은 다음과 같이 정의된 chars_format 플래그를 조합해서 지정할 수 있다.

enum class chars_format
{
scientific, // 스타일: (-)d.ddde ±dd
fixed, // 스타일: (-)ddd.ddd
hex, // 스타일: (-)h.hhhp ±d (주의: 0x는 적지 않는다)
general = fixed | scientific // 다음 문단 참조
}

기본 포맷인 chars_format::general을 적용하면 to_chars()는 부동소수점값을 십진수 표기법인 (-)ddd.ddd와 십진수 지수 표기법인 (-)d.ddde±dd 중에서 소수점 왼쪽에 나오는 숫자를 표기할 때 전체 길이가 가장 짧은 형태로 변환된다. (예: 100.001 보다는 1.0e+6 선택).

포맷에 정밀도(precision)을 지정하지 않으면 주어진 포맷에서 가장 짧게 표현할 수 있는 형태로 결정된다. 참고로 정밀도의 최댓값은 여섯자리다.

반대 방향, 즉 스트링을 숫자로 변환하는 함수도 있다.

from_char_result from_chars(const char* first, const char* last, IntegerT& value, int base = 10);
from_char_result from_chars(const char* first, const char* last, FloatT& value, chars_format format = chars_format::general);

from_chars_result 타입은 아래와 같이 정의돼 있다.

struct from_char_results
{
const char* ptr;
errc ec;
}

여기서 ptr 멤버는 변환에 실패할 경우 첫 번째 문자에 대한 포인터가 되고, 제대로 변환될 때는 last와 같다. 변환된 문자가 하나도 없다면 ptr은 first와 같으며, 에러 코드는 errc::invalid_argument가 된다. 파싱된 값이 너무 커서 지정된 타입으로 표현할 수 없다면 에러 코드의 값은 errc:result_out_of_range가 된다. 참고로 from_char()는 앞에 나온 공백 문자를 무시하지 않는다.

std::string_view 클래스

C++17 이전에는 읽기 전용 스트링을 받는 함수의 매개변수 타입을 쉽게 결정할 수 없었다. const char*로 지정하면 std::string을 사용하는 클라이언트에서 c_str()나 data()를 이용하여 string을 const char*로 변환해서 호출해야 한다.

더 심각한 문제는 이렇게 하면 std:;string의 객체지향 속성과 여기서 제공하는 뛰어난 헬퍼 메서드를 제대로 활용할 수 없다. 그렇다면 매개변수를 const std::string&로 지정하면 될까? 그렇게 하면 항상 std::string만 사용해야 한다. 예컨대 스트링 리터럴을 전달하면 컴파일러는 그 스트링 리터럴의 복사본이 담긴 string 객체를 생성해서 함수로 전달하기 때문에 오버헤드가 발생한다. 간혹 이러한 함수에 대해 오버로딩 버전을 여러 개 만들기도 하는데, 그리 세련된 방법은 아니다.

C++17부터 추가된 std::string_view 클래스를 사용하면 이런 고민을 해결할 수 있다. 이 클래스는 std::basic_string_view 클래스 템플릿의 인스턴스로서 <string_view> 헤더에 정의돼 있다. string_view는 실제로 const string& 대신 사용할 수 있으며 오버헤드도 없다. 다시 말해 스트링을 복사하지 않는다.

string_view 인터페이스는 c_str()이 없다는 점을 제외하면 std::string과 같다. data()는 똑같이 제공된다. string_view는 remove, prefix(size_t)와 remove_suffix(size_t)라는 메서드도 추가로 제공하는데, 지정한 오프셋만큼 스트링의 시작 포인터를 앞으로 당기거나 끝 포인터를 뒤로 미뤄서 스트링을 축소하는 기능을 제공한다.

참고로 string과 string_view를 서로 연결/결합할 수 있다. 예컨대 다음과 같이 작성하면 컴파일 에러가 발생하는데

string str = "Hello";
string_view sv = " world";
auto result = str + sv;

제대로 컴파일 하려면 마지막 줄을 다음과 같이 수정한다.

auto result = str + sv.data();

std::string을 사용할 줄 안다면 string_view의 사용법을 따로 배우지 않고도 곧바로 쓸 수 있다. 다음에 나온 extractExtension() 함수는 주어진 파일명에서 확장자만 뽑아서 리턴한다. 참고로 string_view는 대부분 값으로 전달(pass-by-value)한다. 스트링에 대한 포인터와 길이만 갖고 있어서 복사하는데 오버헤드가 적기 때문이다.

string_view extractExtension(string_view fileName)
{
return fileName.substr(fileName.rfind('.'));
}

함수를 이렇게 저으이하면 모든 종류의 스트링에 적용할 수 있다.

string fileName = R"(c:\temp\my_file.ext)";
cout << "C++ string: " << extractExtension(fileName) << endl;

const char* cString = R"(c:\temp\my_file.ext)";
cout << "C string: " << extractExtension(cString) << endl;

cout << "Literal: " << extractExtension(R"(c:\temp\my_file.ext)") << endl;

여기서 extractExtension()을 호출하는 부분에서 복제 연산이 하나도 발생하지 않는다. extractExtension() 함수의 매개변수와 리턴 타입은 단지 포인터와 길이만 타나낸다. 그래서 굉장히 효율적이다.

string_view 생성자 중에서 원시(raw) 버퍼와 길이를 매개변수로 받는 것도 있다. 이렇나 생성자는 NUL로 끝나지 않는 스트링 버퍼로 string_view를 생성할 때 사용한다. 또한 NUL로 끝나는 스트링 버퍼를 사용할 때도 유용하다. 하지만 스트링의 길이를 이미 알고 있기 때문에 생성자에서 문자 수를 따로 셀 필요는 없다.

const char* raw = /* ... */;
size_t length = /* ... */;
cout << "Raw: " << extractExtension(string_view(raw, length)) << endl;

string_view를 사용하는 것만으로 string이 생성되지는 않는다. string 생성자르 ㄹ직접 호출하거나 string_view::data()로 생성해야 한다. 예컨대 다음과 같이 const string&를 매개변수로 받는 함수가 있다고 하자.

void handleExtension(const string& extension) { /* ... */ }

이 함수를 다음과 같이 호출하면 제대로 작동하지 않는다.

handleExtension(extractExtension("my_file.ext"));

제대로 호출하려면 다음 두 방식 중 하나를 적용한다.

handleExtension(extractExtension("my_file.ext").data());  // data() 메서드
handleExtension(string(extractExtension("my_file.ext")));  // 명시적 ctor

Note) 읽기 전용 스트링을 받는 함수나 메서드의 매개변수 타입은 const std::string&나 const char* 대신 std::string_view로 지정한다.

std::string_view 리터럴

표준 사용자 정의 리터럴인 ‘sv’를 사용하면 스트링 리터럴을 std::string_view로 만들 수 있다.

auto sv = "My string_view"sv;

비표준 스트링

C++ 프로그래머 상당수가 C++ 스타일의 스트링을 사용하지 않는데는 여러 이유가 있다. C++ 규격에 명확히 나오지 않기 때문에 string이라는 타입이 있는 줄도 모르는 사람도 있고, C++ string이 만족스럽지 않아서 직접 정의해서 사용하는 프로그래머도 있고, 마이크로소프트 MFC의 CString 클래스처럼 개발 프레임워크나 운영체제에서 정의한 스트링을 사용하는 경우(주로 하위 호환성이나 레거시 문제를 해결하기 위해)도 있다.

C++ 프로젝트를 시작할 때 구성원이 사용할 스트링을 미리 결정하는 것은 굉장히 중요하다 그중에서도 다음 사항은 반드시 명심해야 한다.

  • C 스타일 스트링은 사용하지 않는다.
  • MFC나 QT 등에서 기본적으로 제공하는 스트링처럼 현재 사용하는 프레임워크에서 제공하는 스트링을 프로젝트의 표준 스트링으로 삼는다.
  • std::string으로 스트링을 표현한다면 함수의 매개변수로 전달할 읽기 전용 스트링은 std::string_view로 지정한다. 스트링을 다른 방식으로 표현한다면 현재 프레임워크에서 제공하는 string_view와 유사한 기능을 활용한다.

전문가를 위한 C++/ C++와 표준 라이브러리 초단기 속성 코스

(전체가 아니라 C#과 차이가 있는 부분을 중심으로 요약 정리)

C++의 기초

프로그래밍 언어의 공식 예제 ‘Hello, World!’

주석

(생략)

전처리 지시자

C++로 작성된 소스 코드를 프로그램으로 만드는 빌드(build) 작업은 세 단계를 거친다.

  1. 전처리(preprocess) 단계에서는 소스 코드에 담긴 메타 정보를 처리한다.
  2. 컴파일(compile) 단계에서는 소스 코드를 머신이 읽을 수 있는 오브젝트(object) 파일로 변환한다.
  3. 링크(link) 단계에서는 앞에서 변환한 여러 오브젝트 파일을 애플리케이션으로 엮는다.

지시자(directive)는 전처리기에 전달할 사항을 표현하며 #include 처럼 # 문자로 시작한다.

main() 함수

프로그램은 항상 main() 함수에서 싲가한다. main() 함수는 int 타입의 값을 리턴하는데, 이 값으로 프로그램의 실행 결과에 대한 상태를 표시한다. main() 함수 안에서는 리턴 문장을 생략해도 되는데 그러면 자동으로 0을 리턴한다. (개인적으로 마음에 드는 디자인은 아니다. 이런 식으로 예외를 허용해주면 일관성이 깨지기 때문)

main() 함수는 매개변수를 받지 않거나 다음과 같이 2개를 받도록 작성할 수 있다.

int main(int argc, char* argv[])

argc는 프로그램에 전달할 인수 개수를 지정하고, argv는 전달할 인수의 값을 담는다. argv[0]에는 프로그램 이름이 담기는데, 공백 스트링으로 지정될 수 있어서 프로그램 이름을 참조하려면 이 값보다는 플랫폼에서 제공하는 기능을 사용하는 것이 좋다. 여기서 주의할 점은 실제 매개변수는 인덱스 1부터 시작한다는 것이다.

I/O 스트림

I/O 스트림은 13장에서 자세히 다루는데, 기본 원리는 굉장히 간단하다. 출력 스트림은 데이터를 나르는 컨베이어 벨트에 비유할 수 있다. 성격에 맞는 컨베이어 벨트에 데이터를 올려두기만 하면 그대로 출력된다.

데이터를 컨베이어 벨트에 올리는 작업은 << 연산자로 표현한다.

std::cout << "There are " << 219 << " ways I love you." << std::endl;

std::endl은 문장이 끝났다는 것을 의미하는데, 출력 스트림에서 std::endl이 나타나면 지금까지 전달한 내용을 모두 출력하고 다음 줄로 넘어간다. 문장의 끝은 \n 자로 표현할 수도 있다.

(이하 네임스페이스/ 리터럴/ 변수/ 연산자/ 타입/ 조건문/ 함수/ 배열/ std::vector/ 구조적 바인딩/ 반복문/ 이니셜라이저 리스트 생략)

C++의 고급기능

C++의 스트링

(생략)

포인터와 동적 메모리

스택과 힙

(생략)

포인터 사용법

메모리 공간을 적당히 할당하기만 하면 어떤 값도 힙에 저장할 수 있다. 

int* myIntegerPointer;

int 타입 뒤에 붙은 별표(*)는 이 변수가 정수 타입에 대한 메모리 공간을 가리킨다는 것을 의미한다. 이때 포인터는 동적으로 할당된 힙 메모리를 가리키는 화살표와 같다.

아직 값을 할당하지 않았기 때문에 포인터가 구체적으로 가리키는 대상은 없는데, 이를 초기화되지 않은 변수라 부른다. 포인터 변수는 초기화하지 않으면 어느 메모리를 기리키는지 알 수 없기 때문에 거의 대부분 프로그램이 뻗어 버린다. 그래서 포인터 변수는 선언하자마자 초기화한다. 포인터 변수에 메모리를 당장할당하고 싶지 않다면 널 포인터(nullptr)로 초기화한다.

int* myIntegerPointer = nullptr;

널 포인터란 정상적인 포인터라면 절대로 가자지 않을 특수한 값이며, 부울 표현식에서는 false로 표현한다.

포인터 변수에 메모리를 동적으로 할당할 때는 new 연산자를 사용한다.

myIntergerPointer = new int;

이렇게 하면 정숫값 하나에 대한 메모리 주소를 가리키며, 이 포인터가 가리키는 값에 접근하려면 포인터를 역참조(dereference) 해야 한다. 역참조란 포인터가 힙에 있는 실젯값을 가리키는 화살표를 따라간다는 뜻이다. 앞서 힙에 새로 할당한 공간에 정숫값을 넣으러면 다음과 같이 작성한다.

*myIntergerPointer = 8;

이 문장은 myIntegerPointer = 8; 과 전혀 다르다. 이 문장에서 변경하는 값은 포인터(메모리 주소)가 아니라 이 포인터가 가리키는 메모리에 있는 값이다.

동적으로 할당한 메모리를 다 쓰고 나면 delete 연산자로 그 공간을 해제해야 한다. 메모리를 해제한 포인터를 다시 사용하지 않도록 곧바로 포인터 변수의 값을 nullptr로 초기화 하는 것이 좋다.

delete myIntegerPointer;
myIntegerPointer = nullptr;

포인터는 힙뿐만 아니라 스택과 같은 다른 종류의 메모리를 가리킬 수도 있다. 원하는 변수의 포인터값을 알고 싶다면 주소 참조 연산자인 &를 사용한다.

int i = 8;
int* myIntegerPointer = &i; // 8이란 값을 가진 변수 i의 주소를 가리키는 포인터

C++은 구조체의 포인터를 다루는 부분을 조금 다르게 표현한다. 다시 말해 먼저 * 연산자로 역참조해서 구조체 자체(시작 지점)에 접근한 뒤 필드에 접근할 때는 . 연산자로 표기한다. 예컨대 다음 코드와 같다. 여기서는 getEmployee() 함수가 Employee 구조체를 리턴한다고 가정한다.

Employee* anEmployee = getEmployee();
cout << (*anEmployee).salary << endl;

위 코드가 복잡해 보이는데, 좀 더 간결하게 표현하고 싶다면 -> (화살표) 연산자로 다음과 같이 표기해도 된다. (화살표 연산자는 *와 . 를 하나로 합친 것)

Employee* anEmployee = getEmployee();
cout << anEmployee->salary << endl;

포인터를 다룰 때 단락 논리를 적용하면 잘못된 포인터에 접근하지 않게 할 수 있다.

bool isValidSalary = (anEmployee != nullptr && anEmployee->salary > 0);

동적으로 배열 할당하기

Note) C에서 사용하던 malloc()이나 free()는 사용하지 말고, new와 delete, new[], delete[]를 사용하라

널 포인터 상수

C++ 11 이전에는 NULL 이란 상수로 널 포인터를 표현했는데, NULL은 실제로 상수 0과 같아서 문제가 발생할 수 있다. 따라서 정식 널 포인터 상수인 nullptr을  사용하라

스마트 포인터

스마트 포인터를 사용하면 메모리와 관련하여 흔히 발생하는 문제를 방지할 수 있기 때문에 스마트 포인터를 사용하는 것이 권장된다. (아무리 메모리 해제를 꼼꼼히 해도 문제가 생길 수 있는 이유가, 실행 중 예외가 발생하는 경우 메모리 해제 코드까지 도달하지 못하기 때문)

C++에서 가장 중요한 스마트 포인터 타입은 다음 두 가지 이다.

  • std::unique_ptr
  • std::shared_ptr

unique_ptr은 포인터로 가리키는 대상이 스코프를 벗어나거나 삭제될 때 할당된 메모리나 리소스도 자동으로 삭제된다는 점을 제외하면 일반 포인터와 같다. 그러나 unique_ptr이 가리키는 객체를 일반 포인터로는 가리킬 수 없다.

unique_ptr은 return 문이 실행되거나 exception이 발생하더라도 항상 할당된 메모리나 리소스를 해제할 수 있다.

unique_ptr을 생성할 때는 반드시 std::make_unique<>()를 사용해야 한다.

auto anEmployee = make_unique<Employee>();

unique_ptr은 제네릭 스마트 포인트라서 어떠한 종류의 메모리도 가리킬 수 있다. 그래서 템플릿으로 만든 것이다.

make_unique()는 C++ 14부터 추가된 것이기 때문에 C++ 14를 지원하지 않는 컴파일러를 사용한다면 다음과 같은 방법으로 unique_ptr을 만든다.

unique_ptr<Employee> anEmployee(new Employee);

스마트 포인터로 지정한 anEmployee의 사용법은 일반 포인터와 같다.

cout << "Salary: " << anEmployee->salary <<< endl;

unique_ptr은 C 스타일 배열을 저장하는데도 활용할 수 있다. 다음 예는 열 개의 Employee 인스턴스로 구성된 배열을 생성하여 이를 unique_ptr에 저장하고 배열에 담긴 원소를 접근하는 방법을 보여주고 있다.

auto employees = make_unique<Employee[]>(10);
cout << "Salary: " << employees[0].salary << endl;

shared_ptr을 사용하면 데이터를 공유할 수 있다. shared_ptr에 대한 대입 연산이 발생할 때마다 레퍼런스 카운트가 하나씩 증가한다. 그래서 shared_ptr가 가리키는 데이터를 레퍼런스 카운트만큼 사용하고 있다는 것을 표현한다. shared_ptr가 스코프를 벗어나면 레퍼런스 카운트가 감소한다. 그러다 레퍼런스 카운트가 0이 되면 그 데이터를 아무도 가지고 있지 않기 때문에 포인터로 가리키던 객체를 해제한다.

shared_ptr는 std::make_shared<>()로 생성한다.

auto anEmployee = make_shared<Employee>();

if (anEmployee)
{
cout << "Salary: " << anEmployee->salary << endl;
}

C++ 17부터 shared_ptr에 배열도 저장할 수 있다. 배열을 저장하는 shared_ptr을 생성할 때는 make_shared<>()를 사용할 수 없고 다음과 같이 작성해야 한다.

shared_ptr<Employee[]> employees(new Employee[10]);
cout << "Salary: " << employees[0].salary << endl;

const의 다양한 용도

const 상수

(생략)

const 매개변수

C++에서는 non-const 변수를 const 변수로 캐스팅할 수 있다. 이렇게 하면 다른 코드에서 변수를 변경하지 않도록 어느 정도 보호할 수 있다.

다음 코드는 mysteryFunction()을 호출할 때 string*을 const string*으로 자동으로 캐스팅한다. 이때 mysteryFunction() 안에서 매개변수로 전달된 스트링의 값을 변경하면 컴파일 에러가 발생한다.

void mysteryFunction(const std::string* someString)
{
*someString = "Test"; // 컴파일 에러
}

int main()
{
std::string myString = "The string";
mysteryFunction(&myString);
return 0;
}

레퍼런스

C++에서 제공하는 레퍼런스를 사용하면 기존 변수에 새 이름을 지정할 수 있다.

int x = 42;
int& xReference = x;

변수의 타입 뒤에 &를 붙이면 그 변수는 레퍼런스가 된다. 코드에서 다루는 방법은 일반 변수와 같지만 내부적으로는 원본 변수에 대한 포인터로 취급한다. 위의 예에서 나온 일반 변수 x와 레퍼런스 변수 xReference는 모두 같은 값을 가리키며, 둘 중 한 변수에서 값을 변경하면 그 결과가 다른 변수에도 반영된다.

레퍼런스 전달 방식

일반적으로 함수에 전달한 변수는 값 전달 방식(pass by value)으로 처리한다. 예컨대 함수의 매개변수에 정수를 전달하면 함수 안에는 그 정수의 복제본이 전다로딘다. 따라서 함수 안에서 원본 변수의 값을 변경할 수 있다.

C에서는 스택 변수에 대한 포인터를 자주 사용했는데, 이런 방식을 사용하면 다른 스택 프레임에 있는 원본 변수를 수정할 수 있다. 이러한 포인터를 역참조하면 그 포인터가 현재 스택 프레임을 가리키지 않더라도 함수 안에서 그 변수가 가리키는 메모리의 값을 수정할 수 있다. 그런데 이 방식은 포인터 연산이 많아져서 간단한 작업이라도 코드가 복잡해진다.

C++에서는 값 전달방식보다 뛰어난 레퍼런스 전달 방식(pass by reference)을 제공한다. 이 방식을 이용하면 매개변수가 포인터값이 아닌 레퍼런스로 전달된다.

예컨대 addOne() 함수를 두 가지 방식으로 구현한 코드를 살펴보자. 첫 번째 함수는 매개변수가 값으로 전달되 함수 안에서는 그 값의 복제본을 조작하기 때문에 원본 변수는 값이 변하지 않는다. 두 번째 함수는 레퍼런스로 전달되기 때문에 원본 변수의 값도 변경된다.

void addOne(int i)
{
i++;
}

void addOne(int& i)
{
i++;
}

레퍼런스를 받는 함수를 호출하는 문장을 작성하는 방식은 일반 함수를 호출할 때와 같다.

복제하는데 부담스러울 정도로 큰 구조체나 클래스를 리턴하는 함수를 구현할 때는 구조체나 클래스를 non-const 레퍼런스로 받아서 원하는 작업을 수행한 뒤 그 결과를 직접 리턴하지 않고 내부에서 곧바로 수정하는 방식을 많이 사용한다. 

하지만 C++ 11부터 추가된 이동 의미론(move semantics) 덕분에 복제하지 않고도 구조체나 클래스를 직접 리턴할 수 있다.

Note) 위 두 함수는 미묘한 차이가 있는데, 값으로 전달하는 버전은 매개변수로 리터럴을 넣어도 문제 없지만, 레퍼런스를 전달하는 버전은 리터럴을 넣을 경우 컴파일 에러가 발생한다.

const 레퍼런스 전달 방식

함수의 매개변수를 const 레퍼런스로 전달하는 코드를 자주 볼 수 있는데, 얼핏 보면 모순되는 표현처럼 보인다. 레퍼런스 매개변수를 사용하면 변수의 값을 수정할 수 있는데 const로 지정하면 그렇게 할 수 없기 때문이다.

const 레퍼런스의 가장 큰 장점은 성능이다. 함수에 매개변수를 값으로 전달하면 그 값 전체가 복제된다. 그러나 레퍼런스로 전달하면 원본에 대한 포인터만 전달되기 때문에 원본 전체를 복제할 필요가 없다. 또한 const로 지정하면 원본 변수가 변경되지도 않는다.

const 레퍼런스는 특히 객체를 다룰 때 유용하다. 객체는 대체로 커서 복제하는 동안 의도하지 않은 효과가 발생할 수 있기 때문이다.

void printString(const std::string& myString)
{
std::cout << myString << std::endl;
}

int main()
{
std::string someString = "Hello World";
printString(someString);
printString("Hello World"); // 리터럴을 전달해도 된다.
return 0;
}

익셉션

C++은 유연성은 뛰어나지만 안정성은 좋지 않다. 메모리 공간을 무작위로 접근하거나 0으로 나누는 연산을 수행하더라도 컴파일러는 가만히 내버려둔다. 

(이하 생략)

타입 추론

auto 키워드

auto 키워드는 다음과 같이 다양한 상황에서 사용한다.

  • 함수의 리턴 타입을 추론한다.
  • 구조적 바인딩에 사용한다.
  • 표현식의 타입을 추론하는데 사용한다.
  • 비타입(non-type, 타입이 아닌) 템플릿 매개변수의 타입을 추론하는데 사용한다.
  • decltype(auto)에서 사용한다.
  • 함수에 대한 또 다른 문법으로 사용한다.
  • 제네릭 람다 표현식에서 사용한다.

auto 키워드를 지정하면 그 변수의 타입은 컴파일 시간에 자동으로 추론해서 결정된다.

auto x = 123; // x는 int 타입으로 결정된다.

auto로 표현식의 타입을 추론하면 함수에 지정된 레퍼런스나 const 한정자가 제거된다.

const std::string message = "Test";

const std::string& foo()
{
return message;
}

foo() 함수의 결과를 auto 타입으로 저장하면 다음과 같다.

auto f1 = foo();

auto를 지정하면 레퍼런스와 const 한정자가 사라지기 때문에 f1은 string 타입이 된다. 따라서 값이 복제되어 버린다. const 타입으로 지정하려면 다음과 같이 auto 키워드 앞뒤에 레퍼런스 타입과 const 키워드를 붙인다.

const auto& f2 = foo();

decltype 키워드

decltype 키워드는 인수로 지정한 표현식의 타입을 알아낸다.

int x = 123;
decltype(x) = 456;

이렇게 작성하면 컴파일러는 y의 타입이 x의 타입인 int라고 추론한다.

decltype은 레퍼런스나 const 지정자를 삭제하지 않는다는 점에서 auto와 다르다. 여기서 string을 가리키는 const 레퍼런스를 리턴하는 함수 foo()를 살펴보자. f2를 다음과 같이 decltype으로 정의하면 const string& 타입이 돼 복제 방식으로 처리하지 않는다.

decltype(foo()) f2 = foo();

얼핏보면 decltype을 사용한다고 특별히 나아질 게 없다고 생각할 수 있지만 템플릿을 사용할 때 상당히 강력한 효과를 발휘한다.

(이하 1장 생략)

이상엽/ 위상수학/ 연속사상

연속사상

연속사상

  • 실수의 연속함수로부터 위상구조의 연속성을 보존하는 연속사상을 정의한다.

Def 1. [실변수함수의 연속]

  1. 함수 f : \mathbb{R} \to \mathbb{R} x_{0} \in \mathbb{R} 에서 연속이다.
    • \Leftrightarrow \forall \epsilon > 0, \exists \delta > 0 : |x - x_{0}| < \delta \Rightarrow |f(x) - f(x_{0})| < \epsilon
    • 즉, x \in (x_{0} - \delta, x_{0} + \delta) \Rightarrow f(x) \in (f(x_{0}) - \epsilon, f(x_{0}) + \epsilon)
  2. f : \mathbb{R} \to \mathbb{R} 가 연속함수다
    • f 가 임의의 x_{0} \in \mathbb{R} 에서 연속이다.

Def 2. [연속사상]

  1. 사상 f : X \to Y x_{0} \in X 에서 연속이다.
    • f(x_{0}) 를 포함하는 임의의 열린집합 V(\subset Y) 에 대하여, x_{0} 를 포함하는 열린집합 U(\subset X) 가 존재해 f(U) \subset V 를 만족한다.
  2. f : X \to Y 가 연속사상이다.
    • f 가 임의의 x_{0} \in X 에서 연속이다.

ex) 집합 X = \{ 1, 2, 3 \} 위의 위상 \mathfrak{I} = \{ \emptyset, X, \{1\}, \{3\}, \{1, 2\}, \{1, 3\} \} 에 대하여, 사상 f : X \to X f(1) = 2, f(2) = 3, f(3) = 3 이라 정의하면, f 는 1과 3에서 연속이지만 2에서는 연속이 아니다. 즉, f 는 연속사상이 아니다.

  • 사상에서 연속임을 증명할 때는 공역에서 먼저 시작해서 그 조건을 만족하는 열린집합을 정의역에서 잡아줄 수 있으면 연속사상이 된다.
    • 이런 조건은 상당히 일반화된 것이기 때문에 직관적으로 이해하기는 쉽지 않다.

Thm 1. [연속사상의 또 다른 정의]

사상 f : X \to Y 에 대하여 다음 두 명제는 동치이다.

  1. f 는 연속사상이다.
  2. Y 의 임의의 열린집합 V 의 역상 f^{-1}(V) X 에서 열린집합이다.
  • 정의에 따라 연속임을 증명하려면 열린집합을 일일이 체크해야 하는데, 이게 너무 번거롭기 때문에 일반적으로 이 정의를 따라 연속임을 판명함.
  • 주의할 점은 역함수를 잡을 수 없는 경우 \emptyset 이 되는데, 이것 또한 위상의 정의상 위상의 원소가 되기 때문에 연속이 된다. 정의가 그러한 것

Cor. [닫힌집합과 연속사상]

f : X \to Y 는 연속사상이다. \Leftrightarrow Y 의 임의의 닫힌집합 C 의 역상 f^{-1}(C) X 에서 닫힌 집합이다.

ex) 실수의 보통위상공간사이의 사상 f(x) = {1 \over 1 + x^{2}} 는 연속사상이지만, 사상 g(x) = \begin{cases} 1, x \geq 0 \\ 0, x < 0 \end{cases} 는 연속사상이 아니다.

  • 정의역이 이산위상공간이면 공역이 무엇이든지 항상 연속사상이 정의된다.
    • 이산위상공간은 모든 부분집합이 열린집합이기 때문에 함수값들이 어떻게 되든간에 상관없이 항상 성립. 애초에 조건에서 근접한 것이 없었기 때문에 그 결과가 근접해 있든 아니든 참이 됨.
    • 이런 경우를 공허참이라 한다. 조건식 P -> Q에서 P가 거짓이면 Q는 무조건 참인 것이 같은 맥락.
  • 정의역과 공역이 같을 때 항등사상과 상수사상은 항상 연속사상이다.

Thm 2. [기저와 연속사상]

위상공간 사이의 사상 f : X \to Y Y 의 기저 \mathcal{B} 에 대해 다음 두 명제는 동치이다.

  1. f 는 연속사상이다.
  2. \mathcal{B} 의 임의의 원소 B 의 역상 f^{-1}(B) X 에서 열린집합이다.

ex) 실수의 아래끝위상공간사이의 사상 f(x) = x + 1 는 연속사상이지만, 사상 g(x) = -x 는 연속사상이 아니다.

Thm 3. [연속사상의 합성]

연속사상의 합성사상은 연속사상이다.

위상동형사상

  • 위상수학의 주요 목표 중 하나는 주어진 두 위상공간이 서로 위상동형인지 아닌지를 밝히는 것이다.

Def. [위상동형사상]

두 위상공간 X, Y 사이의 사상 f 가 다음 세 조건을 만족한다고 하자.

  1. f 는 전단사이다.
  2. f 는 연속이다.
  3. f^{-1} 는 연속이다.

이때 f 를 위상동형사상이라 하며, X Y 를 위상동형이라 하고 X  \simeq Y 라 표기한다.

  • 위상동형인 X, Y 는 1)에 의해 집합적으로 구별되지 않으며 2), 3)에 의해 위상적으로 구별되지 않는다.
  • 위상동형인 위상공간들이 공통적으로 갖는 성질을 불변량이라 한다.
    • 불변하는 성질
  • 위상동형은 동치관계이다.
    • 반사적/ 대칭적/ 추이적

부분공간

부분공간

  • 주어진 하나의 위상공간으로부터 새로운 위상공간을 만든다.

Thm. [부분위상]

위상공간 (X, \mathfrak{I}) A \subset X 에 대하여 \mathfrak{I}_{A} 를 다음과 같이 정의하면 \mathfrak{I}_{A} A 위의 위상이 된다.

\mathfrak{I}_{A} = \{ A \cap U | U \in \mathfrak{I} \}

Def. [부분공간]

Thm.에서 설정한 \mathfrak{I}_{A} 를 부분위상이라 하고, 위상공간 (A, \mathfrak{I}_{A}) (X, \mathfrak{I}) 의 부분공간이라 한다.

  • 전체공간에서는 열린집합이 아니었던 집합이 부분공간에서는 열린집합일 수 있다.
    • 위상공간의 부분 집합에 대하여 공집합과 X(전체 집합)는 서로간에 열린집합-닫힌집합의 관계가 된다. 공집합의 여집합은 X가 되고, X의 여집합은 공집합이 되기 때문. 다시 말해 X를 열린집합으로 잡으면 공집합은 닫힌집합이 되고, 공집합을 열린집합으로 잡으면 X는 닫힌 집합이 된다.
    • 만일 공집합과 X 외에 위상공간의 부분 집합에서 그러한 관계를 갖는 집합이 또 발생한다면, 기하적인 의미에서 그 둘은 떨어져 있는 관계가 된다.
    • 이러한 의미에서 열린집합이라 해서 항상 구간이 열려 있지 않고, –공집합은 원소 1개– 닫힌집합이라고 해서 항상 구간이 닫혀 있지는 않다. –정의상 열린집합의 여집합이기 때문

ex) 실수의 보통 위상공간 (\mathbb{R}, \mathfrak{I}) 의 부분공간 (\mathbb{Z}, \mathfrak{I}_{Z}) 에서 임의의 한 점 집합 \{ z \} (z \in \mathbb{Z})

부분공간의 성질

  1. 위상공간 (X, \mathfrak{I}) \mathfrak{I} 의 기저 \mathcal{B} 그리고 부분위상공간 (A, \mathfrak{I}_A) 에 대하여 \mathcal{B}_{A} = \{ A \cap B | B \in \mathcal{B} \} \mathfrak{I}_{A} 의 기저가 된다.
  2. 위상공간 (X, \mathfrak{I}) 와 그 부분위상공간 (A, \mathfrak{I}_A) 사이에는 항상 위상동형사사을 정의할 수 있다. 즉 (X, \mathfrak{I}) (A, \mathfrak{I}_{A}) 는 위상동형이다.
    • ex) 실수의 보통위상공간 (\mathbb{R}, \mathfrak{I}) (-{\pi \over 2}, {\pi \over 2}) \subset \mathbb{R} 에 대해 사상 f : (-{\pi \over 2}, {\pi \over 2}) \to \mathbb{R} f(x) = \tan x 라 정의하면 f 는 위상동형사상이다.
  3. 위상공간 (X, \mathfrak{I}) 과 그 부분위상공간 (A, \mathfrak{I}_{A}) 에 대해 A (X, \mathfrak{I}) 의 열린집합이면 (A, \mathfrak{I}_{A}) 의 모든 열린집합들은 동시에 (X, \mathfrak{I}) 의 열린집합이기도 하다.

임베딩(Embedding)

  • 공역의 부분공간으로의 사상이 집합적으로도 위상적으로도 겹침이 발생하지 않는 연속사상인 경우를 정의한다.
    • 겹침이 발생하지 않는다는 것은 위상 동형이라는 의미. 원래 함수에서 가까웠던 점은 변환된 함수에서도 가깝고, 원래 함수에서 멀었던 점은 변환된 함수에서 멀다면 겹침이 없는 것이지만, 원래 함수에서 가까웠던 점이 변환된 함수에서 멀어지거나 원래 함수에서 멀었던 점이 변환된 함수에서 가까워지면 겹침이 발생한 것이 된다.

Def. [임베딩]

X 에서 Y 로의 연속사상 f 가 다음 두 조건을 만족하면 임베딩이라 한다.

  1. f 는 단사이다.
  2. \tilde{f} : X \to f(X) 가 위상동형사상이다.

ex) X = \{ 0, 1, 2, ... \} 위의 이산위상 D 와 실수의 보통위상 \mathfrak{I} 에 대하여 두 위상공간 (X, D), (\mathbb{R}, \mathfrak{I}) 사이의 사상 f : X \to Y f(x) = \begin{cases} {1 \over x}, x \neq 0 \\ 0, x = 0 \end{cases} 라 정의하면 f 는 임베딩이 아니다.

  • 위 예는 원래 집합의 원소들은 떨어져 있는데, 변환된 함수의 결과에서는 위상적으로 겹침이 발생함

곱공간

곱공간

  • 주어진 두 위상공간으로부터 새로운 위상공간을 만든다.
    • 주어진 두 집합의 곱집합을 이용

Thm 1. [곱위상의 기저]

두 위상공간 (X, \mathfrak{I}_{X}), (Y, \mathfrak{I}_{Y}) 에 대하여 \mathcal{B} 를 다음과 같이 정의하면 \mathcal{B} 는 곱집합 X \times Y 상의 위상의 기저가 된다.

\mathcal{B} = \{ U \times V | U \in \mathfrak{I}_{X}, V \in \mathfrak{I}_{Y} \}

Def. [곱공간]

Thm 1.에서 설정한 \mathcal{B} 가 생성하는 위상 \mathfrak{I} X \times Y 위의 곱위상이라 하고, 위상공간 (X \times Y, \mathfrak{I}) X Y 의 곱공간이라 한다.

  • 두 거리공간 (X, d_{X}), (Y, d_{Y}) 로부터 유도되는 위상공간을 각각 (X, \mathfrak{I}_{X}), (Y, \mathfrak{I}_{Y}) 라 할 때, 곱거리공간 (X \times Y, d_{X} \times d_{Y}) 로부터 유도되는 위상공간 (X \times Y, \mathfrak{I}) (X, \mathfrak{I}_{X}) (Y, \mathfrak{I}_{Y}) 의 곱공간과 일치한다.
    • 사실은 애초에 거리 공간을 설정할 때 이게 가능하도록 설정한 것

Thm 2. [기저와 곱공간]

두 위상공간 (X, \mathfrak{I}_{X}), (Y, \mathfrak{I}_{Y}) 의 기저를 각각 \mathcal{B}_{X}, \mathcal{B}_{Y} 라 할 때, 집합족 \delta = \{ U \times V | U \in \mathcal{B}_{X}, V \in \mathcal{B}_{Y} \} X \times Y 의 기저이다.

ex) 네 실수 a, b, c, d 에 대해 \delta = \{ (a, b) \times (c, d) | a < b, c < d \} \mathbb{R}^{2} 의 기저이다.

사영사상

Def 1. [열린사상과 닫힌사상]

두 위상공간 (X, \mathfrak{I}_{X}), (Y, \mathfrak{I}_{Y}) 사이의 사상을 f : X \to Y 라 하자.

  1. X 의 임의의 열린집합 U 에 대하여 f(U) Y 의 열린집합이면 f 를 열린사상이라 한다.
  2. X 의 임의의 닫힌집합 C 에 대하여 f(C) Y 의 닫힌집합이면 f 를 닫힌사상이라 한다.
  • 1)전단사이고 2)연속인 3)열린사상은 위상동형사상이다.

Def 2. [사영사상]

두 위상공간 (X, \mathfrak{I}_{X}), (Y, \mathfrak{I}_{Y}) 과 곱공간 (X \times Y, \mathfrak{I}) 에 대하여 p_{1} (x, y) = x, p_{2}(x, y) = y 로 정의한 사상 p_{1}: X \times Y \to X, p_{2} : X \times Y \to Y 를 사영사상이라 한다.

Thm 1. [사영사상의 성질]

사영사상 p_{1} : X \times Y \to X, p_{2} : X \times Y \to Y 에 대하여 다음이 성립한다.

  1. p_{1}, p_{2} 는 연속사상이다.
  2. p_{1}, p_{2} 는 열린사상이다.
  • 위 조건은 만족하지만 전단사가 보장되지 않기 때문에 사영사상은 위상동형사상이 아니다.

Thm 2.

세 위상공간 X, Y, Z 에 대하여 다음 두 명제는 동치이다.

  1. f : Z \to X \times Y 가 연속이다.
  2. p_{1} \circ f : Z \to X p_{2} \circ f : Z \to Y 가 모두 연속이다.

이상엽/ 위상수학/ 위상공간

위상공간

도입

  • 위상수학의 본질은 연속에 대한 이해이며, 실수의 연속성으로부터 시작한다.
    • (연속의 핵심은 극한)

Def 1. [lim_{n \to \infty} x_{n} = L ]

L \in \mathbb{R} 이라 할 때, \forall \epsilon > 0, \exists N \in \mathbb{N} : \forall n \geq \mathbb{N}, |x_{n} - L| < \epsilon

이때 |x_{n} - L| < \epsilon \Leftrightarrow x_{n} \in (L - \epsilon, L + \epsilon)

  • x_{n} \in (L - \epsilon, L + \epsilon) x_{n} L 의 근방에 포함된다는 의미

Def 2. [근방]

N \subset \mathbb{R}, L \in \mathbb{R} 이라 할 때,

\exists(a, b) \subset N : L \in (a, b) 을 만족하면 N L 의 근방이라 한다.

  • 근방이란 L 을 포함하는 열린구간을 의미. 심지어 (-\infty, \infty) 도 정의상 근방이라고도 할 수 있다.
  • 근방 정의의 핵심은 L 로부터 얼마나 떨어져 있느냐가 아니라 연속성.

Thm 1. [근방(열린구간)의 성질]

  1. L \in \mathbb{R} 의 근방들의 유한교집합은 L 의 근방이다.
    • 닫힌 구간인 경우에는 성립하지 않는다. 닫힌 구간들이 1개의 원소를 공유하는 경우 그 교집합은 1개의 원소가 되기 때문에 근방이 되지 않음.
  2. L \in \mathbb{R} 의 근방들의 무한합집합은 L 의 근방이다.
    • 무한 교집합인 경우에는 성립하지 않는다. 교집합이 1개의 원소로 수렴하기 때문

Def 3. [열린집합]

열린구간들의 합집합으로 표현 가능한 집합을 열린집합이라 한다.

Thm 2. [열린집합의 성질]

  1. \emptyset 은 열린집합이다.
  2. 열린집합의 유한교집합은 열린집합이다.
  3. 열린집합의 무한합집합은 열린집합이다.
  • 열린구간의 일반화 버전
    • 열린집합을 0번 합하면 공집합, 1번 합하면 열린구간이 된다.

위상공간

  • 실수에서의 열린집합 성질을 바탕으로 이를 일반화하여 위상공간을 정의한다.
    • 임의의 집합 X 에 열린집합의 성질을 부여한 것이 위상공간

Def 1. [위상과 위상공간]

집합 X (\neq \emptyset) X 의 부분집합의 집합족 \mathfrak{I} 가 다음을 만족한다고 하자.

  1. \emptyset, X \in \mathfrak{I}
    • X 는 열린집합
  2. \forall U_{i} \in \mathfrak{I}, \cap_{i=1}^{n} U_{i} \in \mathfrak{I} (n < \infty)
    • 열린집합의 성질에서 유한 교집합과 같은 내용
  3. \forall U_{i} \in \mathfrak{I}, \cup_{i} U_{i} \in \mathfrak{I}
    • 열린집합의 성질에서 무한 합집합과 같은 내용

이때 \mathfrak{I} X 위의 위상(topology), (X, \mathfrak{I}) 를 위상공간이라 한다.

  • 1.(1).Def3에서 정의한 열린집합들의 집합족 \mathfrak{I} 에 대해 (\mathbb{R}, \mathfrak{I}) 를 실수의 보통위상공간이라 한다.
  • 정의에 의해 한 집합에는 다양한 위상이 존재함을 알 수 있다.

Def 2. [열린집합 개념의 확장]

\mathfrak{I} 가 집합 X 의 위상일 때 \mathfrak{I} 의 원소를 열린집합이라 한다. 즉, 위상공간 (X, \mathfrak{I}) 에 대해 O \in \mathfrak{I} O (\subset X)

ex) 집합 X(\neq \emptyset) 에 대하여 다음은 모두 X 위의 위상이다.

  1. \mathfrak{I} = \{ \emptyset, X \} : 밀착위상
  2. \mathfrak{I} = P(X) 이산위상
  • 모든 집합 X 에 대하여
    • 공집합과 자기 자신(X )을 포함하는 집합족도 X 의 위상이 되고, (최소) 이거를 밀착 위상이라고 한다.
    • 공집합과 자기 자신(X )과 자기 자신의 모든 부분집합을 포함하는 집합족도 X 의 위상이 된다. (최대) – 이게 멱집합이고 이걸 이산 위상이라고 한다.

Def 3. [닫힌집합]

\mathfrak{I} 가 집합 X 의 위상일 때 C^{c} = X - C \in \mathfrak{I} C 를 닫힌집합이라 한다. (열린집합의 여집합)

  • 닫힌집합이라 해서 열린집합이 아닌 것은 아니다. 즉, 열린집합이면서 동시에 닫힌집합인 것도 존재할 수 있다.
    • ex) 실수의 보통위상공간에서 \mathbb{R}

기저

기저

  • 기저로부터 위상을 효율적으로 파악할 수 있을 뿐 아니라 새로운 위상을 만드는 것도 가능하다.

Def. [기저]

집합 X 위의 위상 \mathfrak{I} \mathfrak{I} 의 부분집합 \mathcal{B} 에 대해 \mathfrak{I} 의 임의의 원소가 \mathcal{B} 의 원소의 합집합으로 표현될 수 있으면 \mathcal{B} \mathfrak{I} 의 기저라 한다.

  • \mathcal{B} \mathfrak{I} 의 기저일 떄, \mathcal{B} \subset \mathcal{C} \subset \mathfrak{I} \mathcal{C} \mathfrak{I} 의 기저이다.

Thm. [기저의 또 다른 정의]

위상공간 (X, \mathfrak{I}) \mathfrak{I} 의 부분집합 \mathcal{B} 에 대하여 다음 두 명제는 동치이다.

  1. \mathcal{B} \mathfrak{I} 의 기저이다.
  2. \forall p \in X, p \in U \in \mathfrak{I}, \exists B \in \mathcal{B} : p \in B \subset U

Cor. [기저의 성질]

집합 X 위에 정의된 위상의 기저 \mathcal{B} 는 다음 두 조건을 만족하며, 그 역도 성립한다.

  1. \forall p \in X, \exists B \in \mathcal{B} : p \in B
  2. \forall B_{1}, B_{2} \in \mathcal{B}, \forall p \in B_{1} \cap B_{2}, \exists B_{3} \in \mathcal{B} : p \in B_{3} \subset B_{1} \cap B_{2}

ex) 다음 집합이 생성하는 집합족은 모두 \mathbb{R} 위의 위상이다.

  1. L = \{ [a, b) \subset \mathbb{R} | a, b \in \mathbb{R}, a < b \}
  2. U = \{ (a, b] \subset \mathbb{R} | a, b \in \mathbb{R}, a < b \}

위상크기비교

  • 같은 집합위의 서로 다른 두 위상의 크기를 비교가능한 때가 있으며, 이는 각 위상의 기저를 이용해 효율적으로도 가능하다.

Def. [위상크기비교]

집합 X 위의 두 위상 \mathfrak{I}_{1}, \mathfrak{I}_{2} 에 대하여 \mathfrak{I}_{1} \subset \mathfrak{I}_{2} 이면 \mathfrak{I}_{1} \mathfrak{I}_{2} 보다 작다 (또는 \mathfrak{I}_{2} \mathfrak{I}_{1} 보다 크다)고 한다.

Thm. [기저를 이용한 위상크기비교]

\mathcal{B}_{1}, \mathcal{B}_{2} 가 각각 집합 X 위의 서로 다른 두 위상 \mathfrak{I}_{1}, \mathfrak{I}_{2} 의 기저라 하자. 이때 다음 두 명제는 동치이다.

  1. \mathfrak{I}_{1} \mathfrak{I}_{2} 보다 크다.
  2. \forall p \in X, \forall B_{2} \in \mathcal{B}_{2} ,with, p \in B_{2}, \exists B_{1} \in \mathcal{B}_{1} : p \in B_{1} \subset B_{2}

즉, \mathcal{B}_{1} \supset \mathcal{B}_{2} 이면 \mathfrak{I}_{1} \supset \mathfrak{I}_{2} 이다. 단, 역은 일반적으로 성립하지 않는다.

거리공간

거리공간

  • 위상공간에서 배제된 거리의 개념을 새로이 정의하고, 이를 집합에 부여한 공간을 고려해본다.

Def. [거리]

집합 X 에 대해 함수 d : X \times X \to \mathbb{R} 가 다음 네 조건을 만족한다고 하자.

  1. \forall x, y \in X, d(x, y) \geq 0
  2. d(x, y) = 0 \Leftrightarrow x = y
  3. \forall x, y \in X, d(x, y) = d(y, x)
  4. \forall x, y, z \in X, d(x, y) \leq d(x, z) + d(z, y)

이때 d X 위의 거리(함수), (X, d) 를 거리공간이라 한다.

ex) 다음은 모두 거리공간이다.

  1. \mathbb{R} d(x, y) = |x - y| 에 대해 (\mathbb{R}, d)  
    • 여기서 d 는 유클리드 거리라고 하며 (\mathbb{R}, d)  는 유클리드 공간이라 한다. (보통 d_{E} 로 씀)
  2. \mathbb{R}^{n} = \{ \vec{x} = (x_{1}, ... , x_{n}) | x_{1}, ... , x_{n} \in \mathbb{R} \} d(\vec{x}, \vec{y}) = \sqrt{(x_{1} - y_{1})^{2} + ... + (x_{n} - y_{n})^{2}} 에 대해 (\mathbb{R}^{n}, d)  
  3. 임의의 집합 X d(x, y) = \begin{cases} 1, x \neq y \\ 0, x = y \end{cases}  에 대해 (X, d)  

주어진 두 거리공간 (X_{1}, d_{1}), (X_{2}, d_{2}) 으로부터 다음과 같은 곱거리함수 d_{1} \times d_{2} 를 이용해 새로운 거리공간 (X_{1} \times X_{2}, d_{1} \times d_{2}) 을 만들 수 있다.

(d_{1} \times d_{2})((x_{1}, x_{2}), (y_{1}, y_{2})) = \sqrt{(d_{1}(x_{1}, y_{1}))^{2} + (d_{2}(x_{2}, y_{2}))^{2}}

(단, X_{1} \times X_{2} = \{ (x_{1}, x_{2} | x_{1} \in X_{1}, x_{2} \in X_{2} \} )

거리화 가능 공간

  • 모든 거리공간은 위상공간화 가능하다.
    • 하지만 위상공간이 거리공간으로 변환할 수 없는 것도 존재하기 때문에, 거리공간이 위상공간에 포함되는 개념이 된다. 거리는 위상공간에서 부차적인 요소이다.
    • 거리공간에서 위상공간의 기저가 될 수 있는 것을 만들어 준다.

Def. [열린구]

거리공간 (X, d) 과 임의의 점 x_{0} \in X , 양의 실수 r 에 대하여 X 의 부분집합

B_{d} (x_{0}, r) = \{ x \in X | d(x_{0}, x) < r \}

을 중심이 x_{0} 이고 반지름인 r 인 열린구라하며, 간략히 B_{r}(x_{0}) 로 표기하기도 한다. (임의의 점 x 에서 거리 r 안에 포함되는 모든 점을 가져온 것. r 미만 이기 때문에 열린 구가 된다. 이하이면 닫힌구, 거리와 같은 점을 모으면 구면이 된다)

  • \overline{B_{r}}(x_{0}) = \{ x \in X | d(x_{0}, x) \leq r \} : 닫힌구
  • S_{r}(x_{0}) = \{x \in X | d(x_{0}, x) = r \} : 구면

Thm. [거리공간의 위상공간 유도]

거리공간 (X, d) 에 대하여 모든 열린구들의 집합

\mathcal{B} = \{ B_{r}(x_{0}) | x_{0} \in X, r > 0 \}

는 항상 집합 X 위의 어떤 위상의 기저가 된다. \mathcal{B} 로부터 생성된 위상을 거리위상, 위상공간을 유도공간이라 한다. (모든 열린 구들의 집합이 어떤 위상의 기저가 된다. 그렇게 만든 기저로 위상공간의 모든 요소들을 만들어낼 수 있음)

  • 어떠한 거리공간으로부터도 유도될 수 없는 위상공간이 존재한다.
    • ex) X = \{ 1, 2 \} 에 대한 밀착위상공간
  • 서로 다른 두 거리공간으로부터 동일한 위상공간이 유도되기도 한다.
    • ex) \vec{x} = (x_{1}, x_{2}), \vec{y} = (y_{1}, y_{2}) (\in \mathbb{R}^{2})
    • d_{E}(\vec{x}, \vec{y}) = \sqrt{(x_{1} - y_{1})^{2} + (x_{2} - y_{2})^{2}}
    • d_{M}(\vec{x}, \vec{y}) = Max(|x_{1} - y_{1}|, |x_{2} - y_{2}|)
    • 일 때 (\mathbb{R}^{n}, d_{E}), (\mathbb{R}^{n}, d_{M})
    • d_{E} 는 원의 모양이 되고 d_{M} 는 정사각형 모양이 된다. 그런데 이 두 거리공간으로부터 유도되는 위상공간은 동일하다. 다시 말해 원과 사각형이 위상공간에서는 같은 것이라는 것. 이는 실수라는 무한집합을 이용하였기 때문. 이게 위상수학의 유명한 예.

관계를 다음과 같이 도식해 볼 수 있다.

 

내, 외부와 경계

집적점과 폐포

  • 실수의 극한에 대응하는 위상공간의 개념을 알아본다.
    • 집적점은 실수의 극한의 일반화된 버전
    • 열린구간의 경계에 해당한다.

Def 1. [집적점]

위상공간 (X, \mathfrak{I}) 에 대해 A X 를 부분집합이라 하자. 점 x \in X x 를 포함하는 임의의 열린집합 U 에 대하여

(U \setminus \{x\}) \cap A \neq \emptyset

를 만족하면 x A 의 집적점이라 한다. (x A 에 포함되는지 아닌지 여부는 중요하지 않다)

  • 즉 집합 A 의 집적점이란 A 의 원소들이 한없이 가까이 분포하고 있는 점이다.
  • 실수의 보통위상공간과 유리수집합 \mathbb{Q} 에 대해 모든 실수는 \mathbb{Q} 의 집적점이 될 수 있다.
    • 이처럼 위상공간의 모든 원소를 집적점으로 갖는 집합의 성질을 조밀성이라 한다.
    • 또한 조밀한 가산부분집합이 존재하는 위상공간은 분해가능공간이라 한다.

Thm 1. [닫힌집합의 의미 1]

위상공간 (X, \mathfrak{I}) 의 부분집합 A 에 대해 다음 두 명제는 동치이다.

  1. A 는 닫힌집합이다.
  2. A 의 모든 집적점들은 A 에 포함된다.
    • 닫힌집합은 열린집합의 여집합이기 때문에, 거꾸로 닫힙집합을 찾고 그것의 여집합을 하면 열린집합이 된다. –열린집합을 찾기 어려운 경우 이렇게 한다.
    • 열린집합이란 위상의 원소다.

Def 2. [도집합과 폐포]

위상공간 (X, \mathfrak{I}) 의 부분집합 A 에 대해 A 의 모든 집적점들의 집합을 A 의 도집합 A' 라 하고 A \cup A' A 의 폐포 \overline{A} 라 한다.

  • 집적점이 A 내부에 존재하지 않을 수 있기 때문에 A 의 모든 집적점들의 집합이나 그 집합과 A 의 합집합이 별도의 의미가 있게 된다.

Cor. [닫힌집합의 의미 2]

위상공간 (X, \mathfrak{I}) 의 부분집합 A 에 대해 다음 세 명제는 동치이다. (도집합과 폐포를 이용해서 닫힌집합을 정의할 수 있음)

  1. A 는 닫힌집합이다.
  2. A' \subset A
  3. \overline{A} = A

Thm 2. [폐포의 의미]

위상공간 (X, \mathfrak{I}) 의 부분집합 A 에 대해 다음 두 명제는 동치이다.

  1. x \in \overline{A}
  2. \forall 열린집합 U \ni x, U \cap A \neq \emptyset

내, 외부와 경계

Def. [내부, 외부, 경계]

위상공간 (X, \mathfrak{I}) 의 부분집합 A 에 대해

  1. A 에 포함되는 모든 열린집합의 합집합을 A 의 내부 Int(A) 라 한다.
    • 위상의 원소들 가운데 A 에 포함되는 모든 것을 A 의 내부라고 한다.
  2. A^{c} (= X \setminus A) 의 내부를 A 의 외부 Ext(A) 라 한다.
    • A 의 여집합의 내부(열린집합)가 A 의 외부가 된다.
  3. \overline{A} \cap \overline{A^{c}} A 의 경계 \partial A 라 한다.
    • A 의 폐포와 A 여집합의 폐포의 교집합이 A 의 경계가 된다. 폐포는 직접점(경계)를 포함하고 있기 때문에 실제로 경계가 된다.

Thm. [내부, 외부, 경계의 의미]

위상공간 (X, \mathfrak{I}) 의 부분집합 A 에 대해 다음이 성립한다. (이 부분 집합 A 는 임의의 부분집합이기 때문에 열린집합일 수도 있고 아닐 수도 있다)

  1. \exists 열린집합 U : x \in U \subset A \Leftrightarrow x \in Int(A)
    • A 의 내부에 속하는 점을 포함하면서 A 의 포함하는 집합이 존재한다.
  2. \exists 열린집합 x \in U \subset A^{c} \Leftrightarrow x \in Ext(A)
    • A 의 외부에 속하는 점을 포함하면서 A 의 포함하는 집합이 존재한다.
  3. \exists 열린집합 U \ni x, (U \cap A \neq \emptyset) \wedge (U \cap A^{c} \neq \emptyset) \Leftrightarrow x \in \partial A

Cor. Int(A) \cup Ext(A) \cup \partial A = X

  • 내부, 외부, 경계를 합하면 X 가 된다.

부의 대이동

부의 대이동

부제에 나오는 대로 달러와 금의 흐름에 대한 내용을 담은 책. 코로나 이후 달러와 금이 어떻게 흘러갈 것인가와 그로 인해 벌어질 부의 이동에 대한 내용이 담겨있다.

달러과 금에 대한 투자적 관점에 대한 설명과 함께 현재 일어나고 있는 경제 정책 기반 변화를 다루고 있다. 개인적으로는 이미 삼프로 TV에서 설명을 들었던 내용들이라서 특별하게 새로운 내용은 없었고, 팬심으로 읽었음.

앞으로 3년 경제전쟁의 미래

앞으로 3년 경제전쟁의 미래

삼프로 TV 애청자라면 잘 아는 오건영 팀장의 환율과 금리에 대한 설명을 일본, 한국, 유럽, 중국, 미국에서 일어난 굵직한 사건을 바탕으로 대중들이 이해하기 쉽게 설명하는 책.

자본주의라는 것은 결국 신뢰와 리스크 프리미엄으로 이루어진 시스템이고, 금리와 환율은 그 신뢰의 증표인 화폐에 대한 가격 (자국내 가격이 금리, 나라간 가격이 환율) 임을 생각해 본다면, 금리와 환율이 경제에서 어떤 역할을 하는지 이해할 수 있다. 

개인적으로는 책의 내용을 이미 유튜브 등에서 –오건영 팀장이 아닌 다른 사람들을 통해서도– 많이 접했던 내용이었지만, 초심자들이라면 충분히 도움이 될 듯.