Tag Archives: C++

전문가를 위한 C++/ 에러 처리하기

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

에러와 예외

익셉션의 정체

익셉션이란 코드에서 발생한 ‘예외’ 상황이나 에러가 코드의 정상적인 실행 흐름에 퍼지지 않도록 알려주는 메커니즘이다. 익셉션 메커니즘을 적용하면 에러가 발생한 코드는 익셉션을 던지고(throw) 이를 처리하는 코드는 발생한 익셉션을 받아서 처리(catch) 하는 식으로 작동한다.

익셉션을 처리하는 과정은 기존 프로그램과 달리 순차적으로 실행되지 않는다. 어떤 코드가 익셉션을 던지면 프로그램의 정상 실행 흐름을 잠시 멈추고 익셉션 핸들러(exception handler, 예외처리기)로 제어권을 넘긴다.

이때 핸들러의 위치는 다양하다. 함수 바로 뒤에 나올 수도 있고, 연속된 함수 호출(스택 프레임)을 거슬러 올라가야 나올 수도 있다.

예컨대 아래 그림 14-1처럼 세 함수가 연달아 호출됐을 때 스택 상태를 살펴보자. 익셉션 핸들러가 있는 A()를 호출한 다음 B()를 호출하고, B()는 다시 C()를 호출했는데, C()에서 익셉션이 발생했다고 하자.

위 그림 14-2는 핸들러가 익셉션을 받는 상황을 보여준다. 이 상태를 보면 C()와 B()에 대한 스택 프레임은 삭제됐고 A()에 대한 스택 프레임만 남았다.

C++에서 익셉션이 필요한 이유

기존에 작성된 C 또는 C++ 프로그램을 보면 에러를 처리하는 방식이 제각각이고 체계가 없는 경우가 많다. 함수가 정수 코드를 리턴하거나, errno 매크로를 사용해서 에러를 표시하는 것처럼 C 프로그래밍에서 표준처럼 굳어진 방식이 있는데, 이를 C++에 그대로 적용한 사례도 많다. 또한 스레드를 다룰 때 스레드의 로컬 정수 변수인 errno를 하나씩 만들어주고, 각 스레드가 함수를 호출하 측에 에러를 알려주는데 이 변수를 활용하기도 한다.

하지만 이렇게 정수 타입 리턴 코드나 errno를 사용하는 방식으로 구현하면 에러 처리 과정의 일관성을 유지하기 어렵다.

(예시 생략)

이처럼 일관성 없이 나름대로 정한 관례대로 구현한 함수들이 뒤섞이면 문제가 발생할 수 있다. 호출한 함수가 예상과 다른 방식으로 코드를 리턴하기 때문이다.

(이하 내용 생략)

익셉션 메커니즘을 사용하면 에러를 쉽고 일관성 있고 안전하게 처리할 수 있다. 기존에 C나 C++에서 활용하던 비공식 에러 처리 기법에 비해 익셉션 메커니즘이 뛰어난 점은 다음과 같다.

  • 에러를 리턴값으로 표현하면 호출한 측에서 깜빡하고 리턴값을 검사하지 않거나 상위 함수로 전달하지 못할 수 있다. 반면 익셉션은 깜박 잊고 처리하지 않거나 무시할 수 없다. 발생한 익셉션을 처리하지 않으면 프로그램이 멈추기 때문이다.
  • 에러를 저웃 타입 리턴 코드로 표현하면 구체적인 정보를 담기 힘들다. 반면 익셉션은 에러를 처리하는데 필요한 정보를 마음껏 담을 수 있다.
  • 익셉션 메커니즘은 콜 스택의 중간 단계를 건너뛸 수 있다. 다시 말해 여러 함수가 연속적으로 호출 됐을 떄 중간에 호출된 함수에서 에러를 처리하지 않고 콜 스택의 최상위 함수에서 에러를 처리하게 만들 수 있다.

바람직한 에러 처리 방식

익셉션을 효과적인 에러 처리 기법으로 적극 활용하기 바란다.

익셉션 처리 과정

특히 파일 입출력 과정에서 익셉션이 발생하기 쉽다. 

(예시 코드 생략)

익셉션 던지고 받기

프로그램에 익셉션을 구현한느 코드는 두 부분으로 나뉜다. 하나는 발생한 익셉션을 처리하는 try/catch 문이고, 다른 하나는 익셉션을 던지는 throw 문이다. 둘 다 반드시 지정된 형식에 맞게 작성해야 한다. 하지만 throw 문이 실행되는 지점은 대부분 C++ 런타임과 같이 어떤 라이브러리의 깊숙한 곳에 있어서 프로그래머가 직접 볼 수 없을 때가 많다. 그렇다 하더라도 try/catch 구문으로 반드시 처리해줘야 한다.

try/catch 문은 다음과 같이 구성된다.

try
{
// 익셉션이 발생할 수 있는 코드
}
catch (익셉션_타입_1 익셉션_이름)
{
// 익셉션_타입_1 익셉션을 처리하는 코드
}
catch (익셉션_타입_2 익셉션_이름)
{
// 익셉션_타입_2 익셉션을 처리하는 코드

예외 상황이 발생할 수 있는 코드에 throw 문으로 익셉션을 직접 던져도 된다.

(이하 exception 관련 내용 생략. C#과 개념은 동일하다)

익셉션 타입

던질 수 있는 익셉션의 타입에는 제한이 없다. 앞의 예제에서는 std::exception 타입으로 던졌지만, 다음과 같이 간단히 int 타입 객체를 던져도 된다.

vector<int> readIntegerFile(string_view fileName)
{
ifstream inputStream(fileName.data());

if (inputStream.fail())
{
// 파일 열기 실패: 익셉션을 던진다.
throw 5;
}

// 나머지 코드 생략
}

그러면 catch 문도 다음과 같이 변경한다.

try
{
myInts = readIntegerFile(fileName);
}
catch (int e)
{
cerr << "Unable to open file " << fileName << " (" << e << ")" << endl;
return 1;
}

(이하 예시 코드 생략)

여러 가지 익셉션 던지고 받기

readIntegerFile()에서 파일 열기 실패 말고도 다른 문제가 발생할 수 있기 땜누에 readIntegerFile()을 다음과 같이 수정할 수 있다.

vector<int> readIntegerFile(string_view fileName)
{
ifstream inputStream(fileName.data());

if (inputStream.fail())
{
// 파일 열기 실패: 익셉션을 던진다.
throw runtime_error("Unable to open the file");
}

// 파일에서 정수를 하나씩 읽어 벡터에 추가한다.
vector<int> integers;
int temp;

while (inputStream >> temp)
{
integers.push_back(temp);
}

if (!inputStream.eof())
{
// 파일 끝에 도달하지 못했다면, 다시 말해 파일 읽는 도중 에러가 발생했다면 에러를 던진다.
throw runtime_error("Error reading the file");
}

return integers;
}

앞서 main() 함수를 작성할 때 catch 구문이 runtime_error의 베이스 클래스인 exception 타입을 받도록 지정했기 때문에 여기서는 변경할 필요 없다. 이렇게 하면 catch 문은 두 가지 상황을 모두 처리하게 된다.

try
{
myInts = readIntegerFile(fileName);
}
catch (const exception& e)
{
cerr << e.what() << endl;
return 1;
}

이렇게 하지 않고 readIntegerFile()에서 익셉션을 두 가지 타입으로 따로 나눠서 던져도 된다. 다음 코드를 보면 파일을 열수 없으면 invalid_argument 익셉션을 던지고, 정수를 읽을 수 없으면 runtime_error 익셉션을 던진다.

vector<int> readIntegerFile(string_view fileName)
{
ifstream inputStream(fileName.data());

if (inputStream.fail())
{
// 파일 열기 실패: 익셉션을 던진다.
throw invalid_argument("Unable to open the file");
}

// 파일에서 정수를 하나씩 읽어 벡터에 추가한다.
vector<int> integers;
int temp;

while (inputStream >> temp)
{
integers.push_back(temp);
}

if (!inputStream.eof())
{
// 파일 끝에 도달하지 못했다면, 다시 말해 파일 읽는 도중 에러가 발생했다면 에러를 던진다.
throw runtime_error("Error reading the file");
}

return integers;
}

이제 main()에 invalid_argument를 받는 catch 문과 runtime_error를 받는 catch문을 따로 만든다.

try
{
myInts = readIntegerFile(fileName);
}
catch (const invalid_argument& e)
{
cerr << e.what() << endl;
return 1;
}
catch (const runtime_error& e)
{
cerr << e.what() << endl;
return 2;
}

익셉션 타입 매칭과 const

처리하려는 익셉션 타입에 const가 지정됐는지 여부는 매칭 과정에 영향을 미치지 않는다. 다시 말해 다음 문장은 runtime_error 타입에 속하는 모든 익셉션을 매칭한다.

catch (const runtime_error& e)

다음 문장도 마찬가지로 runtime_error 타입에 속하는 모든 익셉션을 매칭한다.

catch (runtime_error& e)

모든 익셉션 매칭하기

catch 문에서 모든 종류의 익셉션에 매칭하려면 다음과 같이 특수한 문법으로 작성한다.

try
{
myInts = readIntegerFile(fileName);
}
catch (...)
{
cerr << "Error reading or opening file " << fileName << endl;
return 1;
}

점 세 개를 연달아 쓴 부분은 모든 익셉션 타입에 매칭하라는 와일드카드다. 하지만 발생할 익셉션을 확실히 알 수 있다면 이렇게 구현하지 않는 것이 좋다. 필요 없는 익셉션까지 처리하기 때문이다. 항상 익셉션 타입을 구체적으로 지정해서 꼭 필요한 익셉션만 받도록 작성하는 것이 바람직하다.

모든 종류 익셉션을 매칭하는 catch(…) 구문은 디폴트 catch 핸들러를 구현할 때도 유용하다. 익셉션이 발생하면 catch  핸들러가 코드에 나열된 순서대로 검색하면서 조건에 맞는 것을 실행한다. 다음 예는 invalid_argument와 runtime_error만 catch 문을 별도로 작성하고 나머지 익셉션은 디폴트 catch 핸들러로 처리하는 방법을 보여준다.

try
{
// 익셉션이 발생할 수 있는 코드
}
catch (const invalid_argument& e)
{
// invalid_argument 익셉션을 처리하는 핸들러 코드
}
catch (const runtime_error& e)
{
// runtime_error 익셉션을 처리하는 핸들러 코드
}
catch (...)
{
// 나머지 모든 익셉션을 처리하는 핸들러 코드
}

처리하지 못한 익셉션

catch 구문으로 처리하지 못한 익셉션이 남아 있다면 프로그램을 다르게 실행하도록 구현하는 방법도 있다. 예컨대 프로그램이 잡지 못한 익셉션을 만나면 terminate() 함수를 호출하게 만들 수 있다. 이 함수는 C++에서 기본으로 제공하며, 내부적으로 <cstdlib> 헤더에 정의된 abort() 함수를 호출해서 프로그램을 죽인다.

또는 set_terminate()에 인수를 받지 않고 리턴값도 없는 콜백 함수를 포인터로 지정하는 방식으로 terminate_hander를 직접 구현해도 된다. 사용법은 다음과 같다.

try
{
main(argc, argv);
}
catch (...)
{
if (terminate_handler != nullptr)
{
terminate_handler();
}
else
{
terminate();
}
}

// 정상 종료 코드

아쉽지만 여기서 지정한 콜백 함수도 결국 에러를 무시하지 못하고 프로그램을 종료시킨다. 그래도 최소한 종료 직전에 유용한 정보를 담은 에러 메시지를 출력할 기회는 있다.

예컨대 다음 코드를 보면 main()은 커스텀 콜백 함수인 myTerminate()를 terminate_handler로 지정했다. 이 핸들러는 readIntegerFile()이 던지는 익셉션을 제대로 처리하지 않고 그냥 에러 메시지만 출력한 뒤 exit()를 호출해서 프로그램을 종료시킨다. exit() 함수는 프로세스를 종료하는 방식을 표현하는 정숫값을 인수로 받는다. 이렇게 지정한 값은 OS로 전달된다.

void myTerminate()
{
cout << "Uncaught exception!" << endl;
exit(1);
}

int main()
{
set_terminate(myTerminate);

const string fileName = "IntegerFile.txt";
vector<int> myInts = readIntegerFile(fileName);

for (const auto& element : myInts)
{
cout << element << " ";
}
cout << endl;
return 0;
}

여기에는 나오지 않았지만 set_terminate() 함수로 새로운 terminate_handler를 지정하면 기존에 설정된 핸들러를 리턴한다. terminate_handler는 프로그램 전체에서 접근할 수 있기 때문에 처리할 일이 끝나면 이를 리셋하는 것이 바람직하다.

set_terminate()는 반드시 알아야 할 기능 중 하나지만 에러 처리에 가장 효과적인 수단은 아니다. 그보다는 처리할 익셉션을 try/catch 구문에 구체적으로 지정해서 꼭 필요한 익셉션만 제대로 처리하는 것이 바람직하다.

Note) 상용 소프트웨어를 구현할 때는 프로그램이 종료되기 전에 크래시 덤프(crash dump)를 생성하기 위해 terminate_handler를 설정하는 경우가 많다. 이렇게 생성된 크래시 덤프를 디버거에 입력하면 프로그램에서 놓친 익셉션이나 문제의 원인을 알아낼 수 있다.

noexcept

기본적으로 함수가 던질 수 있는 익셉션의 종류에는 제한이 없다. 하지만 함수에 noexcept 키워드를 지정해서 어떠한 익셉션도 던지지 않는다고 지정할 수 있다. 예컨대 앞서 본 readIntegerFile() 함수에 noexcept를 지정하면 익셉션을 하나도 던지지 않는다.

vector<int> readIntegerFile(string_view fileName) noexcept;

noexcept 키워드가 지정된 함수에 익셉션을 던지는 코드가 있으면 C++ 런타임은 terminate()를 호출해서 프로그램을 종료시킨다.

파생 클래스에서 virtual 메서드를 오버라이드할 떄 베이스 클래스에 정의된 메서드에 noexcept가 지정되지 않았더라도 오버라이드하는 메서드에 noexcept를 지정할 수 있다. 하지만 그 반대로는 할 수 없다.

throw 리스트(현재 지원 중단 및 삭제 됨)

이전 버전의 C++에서는 함수나 메서드에서 던질 수 있는 익셉션을 지정할 수 있다. 이를 throw 리스트 또는 익셉션 명세라 부른다.

C++ 17부터 익셉션 명세 기능이 완전히 삭제됐기 때문에 설명하지 않는다. 사실 이전 버전에서도 익셉션 명세 기능을 거의 사용하지 않았다.

(이하 레거시 내용 생략)

익셉션과 다형성

앞서 설명 했듯이 던질 수 있는 익셉션 타입에는 제한이 없지만 대부분 클래스로 정의한다. 익셉션 타입을 클래스로 정의하면 계층 구조를 형성할 수 있기 때문에 익셉션을 처리하는 코드에서 다형성을 활용할 수 있다.

표준 익셉션 클래스의 계층 구조

다음 그림은 표준 익셉션 클래스의 계층 구조를 나타낸다.

C++ 표준 라이브러리에서 던지는 익셉션 객체의 클래스는 모두 이 계층에 속한다. 여기 나온 클래스는 모두 what() 메서드를 갖고 있다. 이 메서드는 익셉션을 표현하는 const char* 타입의 스트링을 리턴하며 에러 메시지 출력에 활용할 수 있다.

익셉션 클래스는 대부분 what() 메서드가 리턴할 스트링을 생성자의 인수로 지정해야 한다. 베이스 클래스인 exception은 반드시 생성자에 이 값을 전달해야 한다.

(이하 설명 및 예시 코드 생략)

클래스 계층 구조에서 정확한 익셉션 타입 선택하기

익셉션 타입을 클래스 계층으로 구성하면 catch 구문에서 다형성을 활용할 수 있다. 

(이하 설명 및 예시 코드 생략)

이렇게 catch 구문이 인수를 exception 레퍼런스로 받으면 exception을 상속한 모든 파생 클래스 타입을 인수로 받을 수 있다. 단 익셉션 계층에서 베이스로 올라갈수록 에러를 구체적으로 처리하기 힘들어지기 때문에 catch 문에서 처리할 추상화 수준에 최대한 맞게 익셉션 타입을 지정하는 것이 바람직하다.

다형성을 이용한 catch 구문이 여러 개라면 코드에 나온 순서대로 매칭된다. 다시 말해 가장 먼저 매칭되는 구문으로 결정된다. 먼저 나온 catch 문이 뒤에 나온 catch 문보다 추상적이면 앞의 것을 먼저 선택한다. 따라서 구체적인 타입을 뒤에 적으면 한 번도 실행되지 않는다.

(이하 설명 및 코드 생략)

익셉션 클래스 직접 정의하기

익셉션을 직접 정의할 때는 반드시 표준 exception 클래스를 직접 또는 간접적으로 상속하는 것이 좋다. 프로젝트 구성원이 모두 이 원칙을 따르면 프로그램에서 발생하는 익셉션이 모두 exception을 상속하게 만들 수 있다. 이렇게 하면 에러 처리 코드에서 다형성을 이용하기 훨씬 쉽다.

(이하 설명 및 코드 생략)

중첩된 익셉션

익셉션을 처리하는 도중에 또 다른 에러가 발생해서 새로운 익셉션이 전달될 수 있다. 아쉽게도 이렇게 중간에 익셉션이 발생하면 현재 처리하고 있던 익셉션 정보가 사라진다. 이를 해결하기 위해 C++는 먼저 잡은 익셉션을 새로 발생한 익셉션의 문맥 안에 포함시키는 중첩된 익셉션(nested exception)이라는 기능을 제공한다.

이 기능은 다음 상황에서도 활용할 수 있다. 현재 구현하는 프로그램에서 A 타입 익셉션을 던지는 서드파티 라이브러리의 함수를 사용하는데, 현재 작성하는 프로그램에서는 B 타입 익셉션만 처리하게 만들고 싶을 수 있다. 이럴 때는 서드파티라이브러리에서 발생하는 익셉션을 모두 B 타입 안에 중첩 시키면 된다.

어떤 익셉션을 처리하는 catch 문에서 새로운 익셉션을 던지고 싶다면 std::throw_with_nested()를 사용하면 된다. 나중에 발생한 익셉션을 처리하는 catch 문에서 먼저 발생했던 익셉션에 접근할 때는 dynamic_cast()를 이용하면 된다. 이때 먼저 발생한 익셉션은 nested_exception으로 표현한다. 구체적인 예를 통해 살펴보자. 먼저 다음과 같이 exception을 상속하고 생성자에서 스트링을 인수로 받는 MyException 클래스를 정의한다.

class MyException : public std::exception
{
public:
MyException(string_veiw message) : mMessage(message) {}
virtual const char* what() const noexcept override
{
return mMessage.c_str();
}

private:
string mMessage;
};

먼저 발생한 익셉션을 처리하다가 새로운 익셉션을 던져야 하는데 그 안에 앞서 발생한 익셉션을 담아 던지려면 std::throw_with_nested() 함수를 호출해야 한다. 다음 코드에 나온 doSomething() 함수에서 runtime_error를 던지는데 바로 다음에 나온 catch 핸들러가 이를 잡아서 처리한다. 

이 핸들러는 몇 가지 메시지를 작성하고 나서 새로운 익셉션을 던지는데 이때 throw_with_nested() 함수를 이용하여 새로운 익셉션 안에 먼저 발생한 익셉션을 담아서 던진다. 익셉션을 중첩시키는 작업은 자동으로 처리된다.

void doSomething()
{
try
{
throw runtime_error("Throwing a runtime_error exception");
}
catch (const runtime_error& e)
{
cout << __func__ << " caught a runtime_error" << endl;
cout << __func__ << " throwing MyException" << endl;
throw_with_nested(MyException("MyException with nested runtime_error"));
}
}

다음 main() 함수는 중첩된 익셉션을 처리하는 방법을 보여준다. 여기서는 doSomething()을 호출하는 코드가 있고, 그 아래에 MyException 익셉션을 처리하는 catch 핸들러가 나온다. 이 핸들러가 익셉션을 잡으면 메시지를 작성한뒤 dynamic_cast()를 이용하여 현재 익셉션에 중첩된 익셉션에 접근한다. 그 안에 중첩된 익셉션이 없다면 널 포인터를 리턴한다. 중첩된 익셉션이 있다면 nested_exception의 rethrow_nested() 메서드를 호출해서 중첩된 익셉션을 다시 던진다. 그러면 다른 try/catch 구문에서 이 익셉션을 처리할 수 있다.

int main()
{
try
{
doSomething();
}
catch (const MyException& e)
{
cout << __func__ << " caught MyException: " << e.what() << endl;
const auto* pNested = dynamic_cast<const nested_exception*>(&e);

if (pNested)
{
try
{
pNested->rethrow_nested();
}
catch (const runtime_error& e)
{
// 중첩된 익셉션을 처리한다.
cout << " Nested exception: " << e.what() << endl;
}
}
}
return 0;
}

여기의 main() 함수는 dynamic_cast()를 이용하여 중첩된 익셉션이 있는지 확인했다. 이렇게 중첩된 익셉션을 확인하기 위해 dynamic_cast()를 호출할 일이 많기 때문에 이 작업을 수행하는 std::rethrow_if_nested()란 간단한 헬퍼 함수를 표준에 정의해뒀다. 이 헬퍼 함수의 사용법은 다음과 같다.

int main()
{
try
{
doSomething();
}
catch (const MyException& e)
{
cout << __func__ << " caught MyException: " << e.what() << endl;

try
{
rethrow_if_nested(e);
}
catch (const runtime_error& e)
{
// 중첩된 익셉션을 처리한다.
cout << " Nested exception: " << e.what() << endl;
}
}
return 0;
}

익셉션 다시 던지기

throw 키워드는 현재 발생한 익셉션을 다시 던질 때 사용한다. 예컨대 다음과 같다.

void g() { throw invalid_argument("Some exception"); }

void f()
{
try
{
g();
}
catch (const invalid_argument& e)
{
cout << "caught in f: " << e.what() << endl;
throw; // 다시 던지기
}
}

int main()
{
try
{
f();
}
catch (const invalid_argument& e)
{
cout << "caught in main: " << e.what() << endl;
}
return 0;
}

여기서 throw e; 와 같은 문장으로 익셉션을 다시 던지면 된다고 생각하기 쉽지만 그러면 안 된다. 익셉션 객체에 대한 슬라이싱이 발생하기 때문이다. 예컨대 f()에서 std::exception을 잡고, main()에서 exception과 invalid_argument 익셉션을 모두 잡으려면 다음과 같이 수정한다.

void g() { throw invalid_argument("Some exception"); }

void f()
{
try
{
g();
}
catch (const exception& e)
{
cout << "caught in f: " << e.what() << endl;
throw; // 다시 던지기
}
}

int main()
{
try
{
f();
}
catch (const invalid_argument& e)
{
cout << "invalid_argument caught in main: " << e.what() << endl;
}
catch (const exception& e)
{
cout << "exception caught in main: " << e.what() << endl;
}
return 0;
}

위 코드를 실행하면 다음과 같이 출력된다.

caught in f: Some exception
invalid_argument caught in main: Some exception

그런데 f()에서 throw; 문장을 throw e;로 바꾸면 실행 결과가 다음과 같다.

caught in f: Some exception
exception caught in main: Some exception

이렇게 하면 main()이 exception 객체를 잡긴 하는데 invalid_argument 객체는 아니다. throw e; 문장에서 슬라이싱이 발생해서 invalid_argument가 exception으로 돼버렸기 때문이다.

Caution) 익셉션을 다시 던질 때는 항상 throw;로 적어야 한다.

스택 풀기와 청소

어떤 코드가 익셉션을 던지면 이를 받아서 처리할 catch 핸들러를 스택에서 찾는다. 이때 catch 핸들러는 현재 스택 프레임에 바로 있을 수도 있고, 몇 단계의 함수 호출 스택을 거슬러 올라가야 나타날 수도 있다. 어떻게든 catch 핸들러를 발견하면 그 핸들러가 정의된 스택 단계로 되돌아가는데, 이 과정에서 중간 단계에 있던 스택 프레임을 모두 풀어버린다. 이를 스택 풀기(stack unwinding)이라 부르며, 스코프가 로컬인 소멸자를 모두 호출하고, 각 함수에서 미처 실행 못한 코드는 건너뛴다.

그런데 스택 풀기가 발생할 때 포인터 변수를 해제하고 리소스를 정리하는 작업은 실행되지 않는다. 때문에 다음과 같은 경우 문제가 발생할 수 있다.

int main()
{
try
{
funcOne(;
}
catch (const exception& e)
{
cerr << "Exception caught!" << endl;
return 1;
}
return 0;
}

void funcOne()
{
string str1;
string* str2 = new string();
funcTwo();
delete str2;
}

void funcTwo()
{
ifstream fileStream;
fileStream.open("fileName");
throw exception();
fileStream.close();
}

funcTwo()에서 익셉션을 던질 때 가장 가까운 핸들러는 main()에 있다. 그래서 실행 흐름은 즉시 funcTwo()에 있던 throw exception(); 문장에서 main()의 cerr << “Exception caught!” << endl; 로 건너뛴다.

funcTwo()의 실행 지점은 익셉션을 던진 문장에 여전히 머물러 있다. 따라서 그 뒤에 나온 다음 문장은 실행되지 않는다.

fileStream.close();

다행히 ifstream 소멸자는 호출된다. fileStream이 스택에 있는 로컬 변수이기 때문이다. ifstream 소멸자가 파일을 대신 닫아주므로 리소스 누수가 발생하지는 않는다. fileStream을 동적으로 할당했다면 제거되지 않기 때문에 파일은 닫히지 않고 그대로 남게 된다.

funcOne()에서 실행 지점이 funcTwo() 호출에 있으므로 그 뒤에 나온 다음 문장은 실행되지 않는다.

delete str2;

따라서 메모리 누수가 발생한다. 스택 풀기 과정에서 str2에 대해 delete를 자동으로 호출해주지 않기 때문이다. 그런데 str1은 제대로 해제된다. 이는 스택에 있는 로컬 변수이기 때문이다. 스택 풀기 과정에서 로컬에 있는 변수는 모두 제대로 해제된다.

바로 이 때문에 C 언어에서 사용하던 할당 모델과 익셉션 같은 최신 프로그래밍 기법을 섞어 쓰면 안 된다. C 방식에서 new를 호출해서 C++ 처럼 보이게 만들어도 마찬가지다. C++로 코드를 작성할 때는 반드시 스택 기반 할당 방식을 적용해야 한다. 그게 힘들다면 다음 두 절에 나온 기법 중 하나를 활용한다.

스마트 포인터 활용

스택 기반 할당 기법을 사용할 수 없다면 스마트 포인터를 활용한다. 그러면 익셉션 처리 과정에 메모리나 리소스 누수 방지 작업 등을 자동으로 처리할 수 있다. 스마트 포인터 객체가 제거될 때마다 그 포인터에 할당된 리소스도 해제된다. 

(이하 설명 및 코드 생략)

Note) 스마트 포인터나 28장에서 소개할 RAII 객체를 사용할 때는 리소스 해제하는 신경 쓰지 않아도 된다. 익셉션이 발생하든 아니면 함수가 정상 종료하든 상관없이 RAII 객체의 소멸자가 알아서 처리하기 때문이다.

익셉션 잡고, 리소스 정리한 뒤, 익셉션 다시 던지기

메모리 및 리소스 누수를 방지하기 위한 또 다른 기법은 각 함수마다 발생 가능한 익셉션을 모두 잡아서 리소스를 제대로 정리한 뒤 그 익셉션을 다시 스택의 상위 핸들러로 던지는 것이다.

(이하 설명 및 코드 생략)

Caution) 익셉션을 잡고 리소스를 정리한 뒤 익셉션을 다시 던지기보다 스마트 포인터나 RAII 클래스를 사용하는 방법이 더 좋다.

익셉션 처리 과정에서 흔히 발생하는 문제

메모리 할당 에러

지금까지 소개한 예제는 모두 메모리 할당 에러가 발생하지 않는다고 가정했다. 현재 흔히 사용하는 64비트 플랫폼에서 이런 일이 발생할 일은 거의 없지만, 모바일 시스템이나 레거시 시스템에서는 메모리 할당 에러가 드물지 않게 발생한다. 이런 시스템에서는 반드시 메모리 할당 에러에 대처하는 코드를 구현해야 한다. C++는 메모리 할당 에러를 처리하기 위한 다양한 기능을 제공한다.

new나 new[]에서 메모리를 할당할 수 없을 때 기본적으로 수행하는 동작은 <new> 헤더 파일에 정의된 bad_alloc 익셉션을 던지는 것이다. 따라서 이 익셉션을 적절히 처리하는 catch 구문을 작성한다.

new나 new[]를 호출할 때마다 try/catch 문으로 감싸도 되지만, 할당하려는 메모리 블록의 크기가 클 때만 이렇게 하는 것이 좋다. 메모리 할당 익셉션을 잡는 방법은 다음과 같다.

int* ptr = nullptr;
size_t integerCount = numeric_limits<size_t>::max();

try
{
ptr = new int[integerCount];
}
catch (const bad_alloc& e)
{
cerr << __FILE__ << "(" << __LINE__ << "): Unable to allocate memory: " << e.what() << endl;

// 메모리 할당 에러를 처리한다.
return;
}

// 메모리 할당에 성공했다면 함수를 정상적으로 진행한다.

위 코드는 미리 정의된 전처리 기호인 __FILE__과 __LINE__을 사용하고 있는데, 컴파일하면 이 기호 자리에 파일 이름과 현재 줄 번호가 들어간다. 이렇게 하면 디버깅이 편하다.

물론 구현하는 프로그램에 따라 실행 과정에서 발생할 수 있는 모든 에러를 프로그램의 최상위 코드에서 try/catch 블록 하나만으로 처리해도 된다.

한 가지 주의할 점은 에러 로깅 과정에 메모리 할당이 발생할 수 있는데, new 과정에 에러가 발생했다면 에러 메시지를 로깅하는데 필요한 메모리도 없을 가능성이 높다.

익셉션을 던지지 않는 new

익셉션 메커니즘을 사용하지 않고 예전 C 방식처럼 메모리 할당에 실패하면 널 포인터를 리턴하도록 작성해도 된다. C++은 익셉션을 던지지 않는 nothrow 버전의 new와 new[]도 제공한다. 이 버전은 메모리 할당에 실패하면 익셉션을 던지지 않고 nullptr을 리턴한다. 이렇게 하려면 new 대신 new (nothrow) 구문을 사용한다.

int* ptr = new (nothrow) int[intergerCount];

if (ptr == nullptr)
{
cerr << __FILE__ << "(" << __LINE__ << "): Unable to allocate memory: " << e.what() << endl;

// 메모리 할당 에러를 처리한다.
return;
}

// 메모리 할당에 성공했다면 함수를 정상적으로 진행한다.

메모리 할당 에러 처리 방식 커스터마이즈 하기

C++은 new 핸들러 콜백 함수를 커스터마이즈 하는 기능을 제공한다. 기본적으로 new나 new[]는 new 핸들러를 따로 사용하지 않고 bad_alloc 익셉션을 던지기만 한다. 그런데 new 핸들러를 정의하면 메모리 할당 루틴에서 에러가 발생했을 때 익셉션을 던지지 않고 저으이된 new 핸들러를 호출한다.

new 핸들러가 리턴하면 메모리 할당 루틴은 메모리를 다시 할당하려 시도하는데, 이때 실패해도 다시 new 핸들러를 호출한다. 따라서 new 핸들러에서 다음 세 가지 중 한 가지 방식으로 구현하지 않으면 무한 루프가 발생할 수 있다. 이 중 몇 가지 방식은 다른 방식보다 낫다.

  • 메모리 추가하기
    • 공간을 확보하기 위한 한 가지 방법은 프로그램 구동시 큰 덩어리의 메모리를 할당했다가 new 핸들러로 해제하게 만드는 것이다. 구체적인 활용 예로 메모리 할당 에러가 발생할 때 현재 사용자의 상태가 사라지지 않도록 저장해야 할 때가 있다. 여기서 핵심은 프로그램을 구동할 때 원하는 상태를 저장할 수 있을 정도로 충분한 양의 메모리를 할당하는데 있다. new 핸들러가 호출되면 이 블록을 해제한 뒤 상태를 저장하고 프로그램을 다시 구동해서 저장된 상태를 불러오면 된다.
  • 익셉션 던지기
    • C++ 표준에서는 new 핸들러에서 익셉션을 던질 때 반드시 bad_alloc이나 이를 상속한 익셉션을 던지도록 명시하고 있다.
  • 다른 new 핸들러 설정하기
    • 이론적으로 new 핸들러를 여러 개 만들어서 각각 메모리를 생성하고 문제가 발생하면 다른 new 핸들러를 설정할 수 있다. 하지만 실제 효과에 비해 코드가 복잡하다는 단점이 있다.

new 핸들러에서 위 세 가지 작업 중 어느 하나라도 하지 않으면 메모리 할당 에러가 발생할 때 무한 루프에 빠진다.

메모리 할당 에러가 발생할 때 new 핸들러를 호출하지 않게 하고 싶다면 new를 호출하기 전에 new 핸들러의 디폴트 값인 nullptr로 잠시 되돌려 준다.

new 핸들러는 <new> 헤더 파일에 선언된 set_new_handler()를 호출해서 설정한다. 예컨대 에러 메시지를 로그에 기록하고 익셉션을 던지도록 new 핸들러를 작성하면 다음과 같다.

class please_terminate_me : public bad_alloc { };

void myNewHandler()
{
cerr << "Unable to allocate memory" << endl;
throw please_terminate_me();
}

new 핸들러에는 반드시 인수와 리턴값이 없어야 한다. 이렇게 하면 앞서 설명한 세 가지 처리 방식 중 두 번째처럼 please_terminate_me가 발생한다.

이렇게 작성한 new 핸들러를 설정하는 방법은 다음과 같다.

int main()
{
try
{
// 새로 만든 new 핸들러를 설정하고 예전 것은 저장해둔다.
new_handler oldHandler = set_new_handler(myNewHandler);

// 할당 에러를 발생시킨다.
size_t numInts = numeric_limits<size_t>::max();
int* ptr = new int[numInts];

// 예전 new 핸들러로 되돌린다.
set_new_handler(oldHandler);
}
catch (const please_terminate_me& e)
{
cerr << __FILE__ << "(" << __LINE << "): Terminating program" << endl;
return 1;
}
return 0;
}

여기서 new 핸들러는 함수 포인터 타입에 대한 typedef이며 set_new_handler()는 이를 인수로 받는다.

생성자에서 발생하는 에러

생성자가 값을 리턴하지 못해도 익셉션을 던질 수는 있다. 익셉션을 활용하면 클라이언트가 객체의 정상 생성 여부를 쉽게 알 수 있다. 하지만 한 가지 심각한 문제가 있다. 익셉션이 발생해서 생성자가 정상 종료되지 않고 중간에 실행을 멈추고 빠져나와버리면 그 객체의 소멸자가 호출될 수 없다. 따라서 익셉션이 발생해서 생성자를 빠져나올 때는 반드시 생성자에서 할당했던 메모리와 리소스를 정리해야 한다. 

(예시 코드 생략)

생성자를 위한 함수 try 블록

지금까지 소개한 익셉션 기능만으로도 함수에서 발생한 익셉션을 처리하는데 충분하다. 그렇다면 생성자 이니셜라이저에서 발생한 익셉션은 어떻게 처리해야 할까? 여기서는 함수 try 블록이란 기능으로 이런 익셉션을 처리하는 방법을 소개한다. 

함수 try 블록은 일반 함수뿐만 아니라 생성자에 적용할 수도 있다. 이 기능이 추가된지 상당히 오래 됐음에도 숙련된 C++ 프로그래머조차 이 기능을 모르는 경우가 많다.

생성자 함수에 대한 함수 try 블록을 작성하는 방법을 의사코드로 표현하면 다음과 같다.

MyClass::MyClass()
try : <생성자 이니셜라이저>
{
/* 생성자 본문 */
}
catch (const exception& e)
{
/* ... */
}

여기서 try 키워드를 생성자 이니셜라이저 바로 앞에 적었다. catch 문은 반드시 생성자를 닫는 중괄호 뒤에 나와야 한다. 그러므로 실질적으로 생성자 밖에 놓인다. 함수 try 블록을 생성자에 적용할 때는 다음과 같은 점에 주의한다.

  • catch 문은 생성자 이니셜라이저나 생성자 본문에서 발생한 익셉션을 잡아서 처리한다.
  • catch 문은 반드시 현재 발생한 익셉션을 다시 던지거나 새 익셉션을 만들어 던져야 한다. catch 문에서 이렇게 처리하지 않으면 런타임이 자동으로 현재 익셉션을 다시 던진다.
  • catch 문은 생성자에 전달된 인수에 접근할 수 있다.
  • catch 문이 함수 try 블록에서 익셉션을 잡으면 생성자의 실행을 정상적으로 마친 베이스 클래스나 그 객체로 된 멤버는 catch 문을 싲가하기 전에 소멸된다.
  • catch 문 안에서는 객체로 된 멤버 변수에 접근하면 안 된다. 바로 앞서 설명한 것처럼 catch 문이 실행되기 전에 소멸되기 때문이다. 그런데 익셉션이 발생하기 전에 그 객체에 논 클래스 타입(예: 일반 포인터 타입) 데이터 멤버를 초기화했다면 여기에 접근할 수 있다. 단, 이런 리소스를 정리하는 작업은 catch 문에서 처리해야 한다. 뒤에 나온 코드가 이렇게 처리한다.
  • 함수 try 블록에 있는 catch 문은 그 안에 담긴 함수에서 값을 리턴할 때 return 키워드를 사용할 수 없다. 생성자는 원래 아무것도 리턴하지 않기 때문이다.

앞서 나열한 제약사항을 감안하면 생성자에 대한 함수 try 블록은 다음과 같은 제한된 상황에만 적합하다.

  • 생성자 이니셜라이저에서 던진 익셉션을 다른 익셉션으로 변환할 때
  • 메시지를 로그 파일에 기록할 때
  • 생성자 이니셜라이저에서 할당한, 소멸자로 자동 제거할 수 없는 리소스를 익셉션을 던지기 전에 해제할 때

다음 예는 함수 try 블록을 구현하는 방법을 보여준다. 여기서 SubObject 클래스는 runtime_error 익셉션을 던지는 생성자 하나만 갖고 있다.

class SubObject
{
public:
SubObject(int i);
};

SubObject::SubObject(int i)
{
throw std::runtime_error(error("Exception by SubObject ctor"));
}

MyClass는 다음과 같이 int* 타입의 멤버 변수 하나와 SubObject 타입의 멤버 변수 하나를 갖고 있다.

class MyClass
{
public:
MyClass();
private:
int* mData = nullptr;
SubObject mSubObject;
}

SubObject 클래스에는 디폴트 생성자가 없다. 다시 말해 mSubObject를 MyClass의 생성자 이니셜라이저로 초기화해야 한다. MyClass 생성자는 함수 try 블록을 이용하여 생성자 이니셜라이저에서 발생한 익셉션을 처리한다.

MyClass::MyClass()
try : mData(new int[42]{ 1, 2, 3 }), mSubObject(42)
{
/* 생성자 바디 */
}
catch (const std::exception& e)
{
// 메모리 정리
delete[] mData;
mData = nullptr;
cout << "function-try-block caught: '" << e.what() << "'" << endl;
}

여기서 명심할 점은 생성자에 대한 함수 try 블록 안에 있는 catch 문은 반드시 현재 익셉션을 다시 던지거나 새 익셉션을 생성해서 던져야 한다. 앞서 나온 catch 문을 보면 아무 익셉션도 던지지 않는데, 그러면 C++ 런타임이 현재 익셉션을 대신 던져준다. 앞서 정의한 클래스를 사용하는 예는 다음과 같다.

int main()
{
try
{
MyClass m;
}
catch (const std::exception& e)
{
cout << "main() caught: '" << e.what() << "'" << endl;
}
return 0;
}

이 코드를 실행하면 다음과 같이 출력된다.

function-try-block caught: 'Exception by SubObject ctor'
main() caught: 'Exception by SubObject ctor'

참고로 이 예제처럼 코드를 작성하면 위험하다. 초기화 순서에 따라 catch 문에 진입할 때 mData에 이상한 값이 할당될 수 있다. 이렇게 알 수 없는 값을 가진 포인터에 대해 delete를 호출하면 예상치 못한 동작이 나타날 수 있다. 그래서 위 예제에서 이런 문제를 방지하려면 mData 멤버를 std::unique_ptr과 같은 스마트 포인터로 선언하고 함수 try 블록을 제거하는 것이다.

Caution) 함수 try 블록은 사용하지 않는 것이 좋다.

함수 try 블록은 생성자 뿐만 아니라 일반 함수에도 적용할 수 있지만 일반 함수에서 이를 사용해서 나아질 것은 없다. 함수 본문 안에서 간단히 try/catch 문으로 표현해도 되기 때문이다. 단 함수 try 블록을 생성자에서 사용할 때와 달리 일반 함수에서 사용하면 현재 익셉션을 다시 던지거나 catch 문에서 새 익셉션을 던질 필요가 없고, C++ 런타임에서 대신 던져주지도 않는다.

소멸자에서 익셉션을 처리하는 방법

소멸자에서 발생하는 에러는 반드시 소멸자 안에서 처리해야 한다. 소멸자에서 익셉션을 다른 곳으로 던지면 안 된다. 그 이유는 다음과 같다.

  1. 소멸자를 명시적으로 noexcept(false)로 지정하지 않거나 그 클래스에 있는 객체 중 소멸자에 noexcept(false)가 지정된 것이 없다면 내부적으로 noexcept로 선언된 것으로 취급한다. noexcept 소멸자에서 익셉션을 던지면 C++ 런타임은 std::terminate()를 호출해서 프로그램을 종료한다.
  2. 소멸자는 이미 다른 익셉션이 발생해서 스택 풀기를 수행하는 과정에서도 실행될 수 있다. 스택 풀기를 하는 도중에 소멸자에서 익셉션을 던지면 C++ 런타임은 std::terminate()를 호출해서 애플리케이션을 종료한다. 
    • 실험 정신이 투철한 독자를 위해 부연 설명하면 C++는 소멸자가 호출되는 원인이 일반 함수의 정상적인 종료 때문인지 아니면 delete를 호출했기 때문인지 아니면 스택 풀기 때문인지 알아내는 기능을 제공한다.
    • <exception> 헤더 파일에 선언된 uncaught_exceptions() 함수를 호출하면 아직 잡지 않은 익셉션, 즉 이미 발생했지만(던져졌지만) 아직 catch 문에는 매칭되지 않은 익셉션 수를 리턴한다. uncaught_exceptions()의 리턴값이 0보다 크면 스택 풀기 과정에 있다는 뜻이다. 하지만 이 함수를 제대로 활용하기 힘들고 코드도 지저분해져서 사용하지 않는 것이 좋다.
    • 참고로 C++ 17 이전에는 이 함수의 이름이 단수형인 uncaught_exception()이었고 bool 타입의 값을 리턴했다. 즉, true를 리턴하면 현재 스택 풀기 중에 있다는 뜻이다.
  3. 그렇다면 클라이언트는 어떤 액셥을 취해야 할까? 클라이언트는 소멸자를 직접호출하지 않고 delete를 이용하여 간접적으로 소멸자를 호출한다. 그런데 소멸자에서 익셉션을 던지면 클라이언트를 어떻게 처리해야 할까? 이미 delete를 호출한 객체에 다시 delete를 호출할 수도 없고, 소멸자를 직접 호출할 수도 없다. 이처럼 클라이언트가 할 수 있는 ㅇ리이 없기 땜누에 굳이 익셉션 처리의 부담을 줄 이유가 없다.
  4. 소멸자는 객체에서 사용할 메모리나 리소스를 해제할 마지막 기회다. 함수 실행 도중에 익셉션을 던져 이 기회를 놓쳐버리면 다시 돌아가 메모리나 리소스를 해제할 수 없다.

모두 합치기

(앞선 내용들을 합한 예시 코드 생략)

 

 

 

 

 

 

 

전문가를 위한 C++/ C++ I/O 완전 분석

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

스트림 사용법

스트림의 정체

1장에서 cout 스트림을 소개할 때 공장의 컨베이어 벨트에 비유했다. 스트림에 변수를 올려 보내면 사용자의 화면인 콘솔에 표시된다. 이를 일반화해서 모든 종류의 스트림을 컨베이어 벨트로 표현할 수 있다.

스트림마다 방향과 소스(source) 또는 목적지(destination)을 지정할 수 있다. 예컨대 앞 장에서 본 cout 스트림은 출력 스트림이다. 그래서 나가는(out) 방향을 갖는다. cout은 데이터를 콘솔에 쓴다. 따라서 목적지는 ‘콘솔’이다. cout의 c는 console이 아니라 character를 의미한다. 즉, cout은 문자 기반 스트림이다. 이와 반대로 사용자의 입력을 받는 cin이란 스트림도 있다.

C++에서 기본으로 정의된 스트림을 간략히 정리하면 다음과 같다.

스트림 설명
cin 입력 스트림. ‘입력 콘솔’에 들어온 데이터를 읽는다.
cout 버퍼를 사용하는 출력 스트림. 데이터를 ‘출력 콘솔’에 쓴다.
cerr 버퍼를 사용하지 않는 출력 스트림. 데이터를 ‘에러 콘솔’에 쓴다. 에러 콘솔과 ‘출력 콘솔’이 같을 때가 많다.
clog 버퍼를 사용하는 cerr

여기서 버퍼를 사용하는 (buffered) 스트림은 받은 데이터를 버퍼에 저장했다가 블록 단위로 목적지를 보내고, 버퍼를 사용하지 않는 (unbuffered) 스트림은 데이터가 들어오자마자 목적지로 보낸다. 이렇게 버퍼에 잠시 저장(버퍼링, buffering) 하는 이유는 파일과 같은 대상에 입출력을 수행할 때는 블록 단위로 묶어서 보내는 것이 효율적이기 때문이다. 참고로 버퍼를 사용하는 스트림은 버퍼를 깨끗이 비우는 flush() 메서드로 현재 버퍼에 담긴 데이터를 목적지로 보낸다.

스트림에서 중요한 또 다른 사실은 데이터가 현재 가리키는 위치와 함께 담겨 있다는 것이다. 스트림에서 현재 위치란 다음번에 읽기 또는 쓰기 연산을 수행할 위치를 의미한다.

스트림의 출발지와 목적지

스트림이란 개념은 데이터를 입력 받거나 출력하는 객체라면 어떤 것에도 적용할 수 있다. 네트워크 관련 클래스를 스트림 기반으로 작성할 수도 있고, MIDI 장치에 접근하는 부분도 스트림으로 구현할 수 있다.

파일 스트림(file stream)은 파일 시스템에서 데이터를 읽고 쓰는 스트림이다. 스트링 스트림(string stream)은 스트링 타입에 스트림 개념을 적용한 것이다.

스트림을 이용한 출력

출력 스트림의 기초

출력 스트림은 <ostream> 헤더 파일에 정의돼 있다. 출력 스트림을 사용하는 가장 간편한 방법은 << 연산자를 이용하는 것이다.

(앞서 많이 다루었기 때문에 이하 cout에 대한 예시 생략)

출력 스트림에서 제공하는 메서드

출력 스트림에서 가장 대표적인 연산자는 << 다. 이 연산자는 단순히 출력하는 기능 외에도 여러 기능을 제공한다.

put()과 write()

put()과 write()는 저수준 출력 메서드에 속하며 출력 동작을 갖춘 객체나 변수가 아닌 문자 하나 (put()) 또는 문자 배열 하나 (write())을 인수로 받는다.

const char* test = "hello there\n";
cout.write(test, strlen(test));

cout.put('a');
flush()

출력 스트림에 데이터를 쓰는 즉시 목적지에 전달되지 않을 수 있다. 일반적으로 출력 스트림은 들어온 데이터를 곧바로 쓰지 않고 버퍼에 잠시 보관한다. 그렇게 하면 성능을 높일 수 있기 때문이다. 목적지가 파일과 같은 스트림일 떄는 한 문자씩 처리하기 보다 블록 단위로 묶어서 처리하는 것이 훨씬 효율적이다.

스트림은 다음과 같은 조건을 만족할 떄 그동안 쌓아둔 데이터를 모두 내보내고 버퍼를 비운다.

  • endl과 같은 경곗값에 도달할 떄
  • 스트림이 스코프를 벗어나 소멸될 때
  • 출력 스트림에 대응되는 입력 스트림으로부터 요청이 들어올 때(예컨대 cin으로 입력 받으면 cout의 버퍼를 비움)
  • 스트림 버퍼가 가득 찼을 때
  • 스트림 버퍼를 비우기 위해 명시적으로 flush()를 호출할 때

 flush() 메서드를 호출해서 스트림 버퍼를 명시적으로 비우려면 다음과 같이 작성한다.

cout << "abc";
cout.flush();
cout << "def";
cout.flush();

Note) 모든 출력 스트림이 버퍼를 사용하는 것은 아니다. cerr 스트림은 버퍼를 사용하지 않고 출력한다.

출력 에러 처리하기

good() 메서드는 스트림을 정상적으로 사용할 수 있는 상태인지 확인한다. 사용법은 다음과 같이 스트림에 대해 곧바로 호출하면 된다.

if (cout.good())
{
cout << "All good" << endl;
}

good() 메서드를 이용하면 스트림의 상태 정보를 조회할 수 있다. 하지만 사용할 수 없는 상태일 때는 그 원인을 구체적으로 알려주지 않는다. 이런 정보는 bad() 메서드로 자세히 볼 수 있다. bad() 메서드가 true를 리턴한다는 말은 심각한 에러가 발생했다는 뜻이다. (반면 파일의 끝에 도달했는지 확인하는 eof()가 true라는 것은 심각한 상태가 아니다) 

또한 fail() 메서드를 사용하면 최근 수행한 연산에 오류가 발생했는지 확인할 수 있다. 그러나 그 뒤에 일어날 연산의 상태는 알려주지 않기 때문에 fail()의 리턴값에 관계 없이 후속 연산이 성공적으로 수행할 수도 있고 아닐 수도 있다. 예컨대 출력 스트림에 대해 flush()를 호출할 뒤 fail()을 호출하면 바로 직전의 flush() 연산이 성공했는지 확인할 수 있다.

cout.flush();

if (cout.fail())
{
cerr << "Unable to flush to standard out" << endl;
}

스트림을 bool 타입으로 변환하는 연산자도 있다. 이 연산자는 !fail()을 호출할 떄와 똑같은 결과를 리턴한다. 따라서 앞에 나온 코드를 다음과 같이 작성해도 된다.

cout.flush();

if (!cout)
{
cerr << "Unable to flush to standard out" << endl;
}

여기서 주의할 점은 good()과 fail()은 스트림이 파일 끝에 도달할 때도 false를 리턴한다는 것이다. 이 관계를 코드로 표현하면 다음과 같다.

good() == (!fail() && !eof())

스트림에 문제가 있으면 익셉션을 발생하도록 만들 수 있다. ios_base::failure 익셉션을 처리하도록 catch 구문을 작성하면 된다. 이 익셉션에 대해 what() 메서드를 호출하면 발생한 에러에 대한 정보를 볼 수 있다. 또한 code()를 호출하면 에러 코드를 볼 수 있다. 하지만 이 정보가 얼마나 쓸모 있는지는 표준 라이브러리의 구현마다 다르다.

cout.exceptions(ios::failbit | ios::badbit | ios::eofbit);

try
{
cout << "Hello World" << endl;
}
catch (const ios_base::failure& ex)
{
cerr << "Caught exception: " ex.what() << ", error code = " << ex.code() << endl;
}

스트림의 에러 상태를 초기화하려면 clear() 메서드를 호출한다.

cout.clear();

출력 매니퓰레이터

C++의 스트림은 단순히 데이터를 전달하는데 그치지 않고, 매니퓰레이터(manipulator, 조종자)라는 객체를 받아서 스트림의 동작을 변경할 수도 있다. 이때 스트림의 동작을 변경하는 작업만 할 수도 있고, 스트림에 데이터를 전달하면서 동작도 변경할 수 있다.

앞서 본 endl이 바로 스트림 매니퓰레이터다. endl은 데이터와 동작을 모두 담고 있다. 그래서 스트림에 전달될 때 줄끝(end-of-line) 문자를 출력하고 버퍼를 비운다. 몇 가지 유용한 스트림 매니퓰레이터를 소개하면 다음과 같다. 대부분 <ios>나 <iomanip> 헤더 파일에 정의돼 있다.

  • boolalpha와 noboolalpha: 스트림에 bool 값을 true나 false로 출력하거나(boolalpha), 1이나 0으로 출력하도록(noboolalpha) 설정한다. 기본값은 noboolalpha다.
  • hex, oct, dec: 각각 숫자를 16진수, 8진수, 10진수로 출력한다.
  • setprecision: 분숫값을 표현할 때 적용할 소수점 자리수를 지정한다. 이를 위해 자릿수를 표현하는 인수를 받는다.
  • setw: 숫자 데이터를 출력할 필드의 너비를 지정한다. 이 매니퓰레이터도 인수를 받는다.
  • setfill: 지정된 너비보다 숫자가 작을 때 빈 공간을 채울 문자를 지정한다. 이 매니퓰레이터도 인수를 받는다.
  • showpoint와 noshowpoint: 소수점 아래의 수가 없는 부동소수점수를 스트림에서 표현할 때 소수점의 표시 여부를 설정한다.
  • put_money: 스트림에서 화폐 금액을 일정한 형식에 맞게 표현할 때 사용하는 매니퓰레이터로서 인수를 받는다.
  • put_time: 스트림에서 시간을 일정한 형식에 맞게 표현할 때 사용하는 매니퓰레이터로서 인수를 받는다.
  • quoted: 지정한 스트링을 인용부호(따옴표)로 감싸고 스트링 안에 있던 인용부호를 이스케이프 문자로 변환한다. 이 매니퓰레이터도 인수를 받는다.

(예시 코드 생략)

스트림을 이용한 입력

입력 스트림의 기초

입력 스트림으로부터 데이터를 읽는 두 가지 방법이 있다. 하나는 출력 연산자 <<로 데이터를 출력하는 방법과 비슷하며 << 대신 입력 연산자 >>를 사용한다. 이때 >> 연산자로 입력 스트림에서 읽은 데이터를 변수에 저장할 수 있다. 예컨대 사용자로부터 단어 하나를 받아서 스트링에 저장한 뒤 콘솔에 출력하려면 다음과 같이 작성한다.

string userInput;
cin >> userInput;
cout << "User input was " << userInput << endl;

>> 연산자의 기본 설정에 따르면 공백을 기준으로 입력된 값을 토큰 단위로 나눈다(토큰화 한다) 예컨대 앞서 나온 코드를 실행한 뒤 콘솔에서 ‘hello there’를 입력하면 첫 번쨰 공백 문자(스페이스) 이전의 문자들만 userInput 변수에 담긴다. 출력 결과는 다음과 같다.

User input was hello

또 다른 방법은 get()을 사용하는 것이다. 그러면 입력값에 공백을 담을 수 있다.

>> 연산자는 <<와 마찬가지로 다양한 타입을 지원한다. 예컨대 정숫값을 읽으려면 다음처럼 변수의 타입만 바꾸면 된다.

int userInput;
cin >> userInput;
cout << "User input was " << userInput << endl;

또한 타입이 다른 값을 동시에 받을 수 있다. 

(예시 코드 생략)

입력 에러 처리하기

입력 스트림은 비정상적인 상황을 감지하는 여러 메서드를 제공한다. 입력 스트림의 에러는 대부분 읽을 데이터가 없을 때 발생한다. 예컨대 스트림의 끝(파일 끝 end-of-file)에 도달할 때가 있다. 이에 대처하는 가장 흔한 방법은 입력 스트림에 접근하기 전에 조건문으로 스트림의 상태를 확인하는 것이다. 예컨대 다음 반복문은 cin이 정상 상태일 때만 진행한다.

while (cin) { ... }

이때 데이터 입력을 받아도 된다.

while (cin >> ch) { ... }

출력 스트림과 마찬가지로 입력 스트림에 대해서도 good(), bad(), fail() 메서드를 호출할 수 있다. 또한 스트림이 끝에 도달하면 true를 리턴하는 eof() 메서드도 사용할 수 있다. 

입력 스트림의 good()과 fail()은 출력 스트림과 마찬가지로 파일 끝에 도달하면 false를 리턴하며 출력 스트림처럼 다음과 같은 관계가 성립한다.

good() == (!fail() && !eof())

따라서 데이터를 읽을 때마다 항상 스트림 상태를 검사하는 습관을 들인다. 그래야 잘못된 값이 입력될 때 적절히 대처할 수 있다.

(이하 예시 생략)

입력 메서드

출력 스트림과 마찬가지로 입력 스트림도 >> 연산자보다 저수준으로 접근하는 메서드를 제공한다.

get()

get() 메서드는 스트림 데이터를 저수준으로 읽는다. get()의 가장 간단한 버전은 스트림의 다음 문자를 리턴한다. 물론 여러 문자를 한 번에 읽는 버전도 있다. get()은 주로 >> 연산자를 사용할 떄 자동으로 토큰 단위로 잘리는 문제를 피하는 용도로 사용한다. 예컨대 다음 함수는 입력 스트림에서 이름 하나를 받는다. 이때 이름이 여러 단어로 구성될 수 있으므로 스트림의 끝에 도다할 때까지 이름을 계속 읽는다.

string readName(istream& stream)
{
string name;

while(stream) // 또는 while(!stream.fail())
{
int next = stream.get();
if (!stream || next == std::char_traits<char>::eof())
{
break;
}
name += static_cast<char>(next);
}

return name;
}

readName() 함수를 구현하는 과정에서 몇 가지 주목할 점이 있다.

  • 매개변수의 타입은 non-const istream 레퍼런스다. 스트림에서 데이터를 읽는 메서드는 실제 스트림을 (그중에서도 특히 위치를) 변경하기 때문에 const로 지정하지 않았다. 따라서 const 레퍼런스에 대해 호출할 수 없다.
  • get()의 리턴값은 char가 아닌 int 타입 변수에 저장했다. get()은 EOF에 해당하는 std::char_traits<char>::eof()를 비롯한 문자가 아닌 특수한 값을 리턴할 수 있기 떄문이다.

여기 나온 readName() 코드는 반복문을 끝내는 방법이 두 가지라는 점이 특이하다. 하나는 스트림이 오류 상태에 빠질 떄고, 다른 하나는 스트림의 끝에 도달할 때다. 일반적으로 스트림에서 데이터를 읽는 부분을 구현할 때는 여기 나온 방식보다는 문자에 대한 레퍼런스를 받아서 스트림에 대한 레퍼런스를 리턴하는 버전의 get()을 이용하는 방식을 많이 사용한다. 이렇게 작성하면 입력 스트림이 에러 상태가 아닐 때만 조건문에서 true를 리턴한다는 점을 활용할 수 있다. 즉, 스트림에 에러가 발생하면 조건문으로 적은 표현식의 결과는 false가 된다. 이렇게 하면 코드를 다음과 같이 훨씬 간결하게 작성할 수 있다.

string readName(istream& stream)
{
string name;
char next;

while(stream.get(next))
{
name += next;
}

return name;
}
unget()

일반적으로 입력 스트림은 한 방향으로만 진행하는 컨베이어 벨트와 같다. 여기에 올린 데이터는 변수로 전달된다. 그런데 unget() 메서드는 데이터를 다시 입력 소스 방향으로 보낼 수 있다는 점에서 이 모델을 따르지 않는다.

unget()을 호출하면 스트림이 한 칸 앞으로 거슬러 올라간다. 그래서 이전에 읽은 문자를 스트림으로 되돌린다. unget() 연산의 성공 여부는 fail() 메서드로 확인한다. 예컨대 현재 위치가 스트림의 시작점이면 unget()에 대한 fail()의 리턴값은 false다.

앞서 예시 함수는 공백이 담긴 이름을 입력 받을 수 없었다. unget()을 이용하면 공백을 담을 수 있다.

(예시 코드 생략)

putback()

putback() 메서드도 unget()과 마찬가지로 입력 스트림을 한 문자만큼 되돌린다. unget()과 달리 putback()은 스트림에 되돌릴 문자를 인수로 받는다.

char ch1;
cin >> ch1;
cin.putback('e');
// 이 스트림에서 다음 번에 읽어올 문자는 'e'가 된다.
peek()

peek()은 ‘힐끗 본다’는 의미대로 get()을 호출할 떄 리턴될 값을 미리 보여준다.

(예시 코드 생략)

getline()

프로그램을 작성하다 보면 입력 스트림에서 데이터를 한 줄씩 읽을 일이 많다. 이를 위해 getline()이란 메서드를 별도로 제공한다. 이 메서드는 미리 설정한 버퍼가 가득 채워질 때까지 문자 한 줄을 읽는다. 이때 한 줄의 끝을 나타내는 \0(EOL, end-of-line) 문자도 버퍼의 크기에 포함된다.

다음 코드는 cin으로부터 kbufferSize-1개의 문자를 읽거나 EOL 문자가 나올 때까지 읽기 연산을 수행한다.

char buffer[kBufferSize = { 0 };
cin.getline(buffer, kBufferSize);

getline()이 호출되면 입력 스트림에서 EOL이 나올 때까지 문자 한 줄을 읽는다. EOL 문자는 스트링에 담기지 않는다. 참고로 EOL 문자는 플랫폼마다 다를 수 있는데, 어떤 것은 \r\n을 사용하고 어떤 것은 \n이나 \n\r을 사용한다.

get() 함수 중에서 getline()과 똑같이 작동하는 버전도 있다. 단, 이 함수는 입력 스트림에서 줄바꿈 문자를 가져오지 않는다.

C++의 string에서 사용할 수 있는 std::getline()이란 함수도 있다. 이 함수는 <string> 헤더파일의 std 네임스페이스 아래에 정의돼 있다. 이 함수는 스트림 레퍼런스와 string 레퍼런스를 받고, 옵션으로 구분다(delimiter)도 받는다. std::getline()을 사용하면 버퍼의 크기를 지정하지 않아도 된다는 장점이 있다.

string myString;
std::getline(cin, myString);

입력 매니퓰레이터

C++는 다음과 같은 입력 매니퓰레이터를 기본으로 제공한다. 이를 입력 스트림에 적절히 지정하면 데이터를 읽는 방식을 원하는대로 설정할 수 있다.

  • boolalpha와 noboolalpha: boolalpha를 지정하면 ‘false’란 스트링값을 부울 타입인 false로 해석하고 나머지 스트링을 true로 처리한다. noboolalpha를 지정하면 0을 부울값 false로 해석하고, 0이 아닌 나머지 값을 true로 처리한다. 기본적으로 noboolalpha로 설정돼 있다.
  • hex, oct, dec: 각각 숫자를 16진수, 8진수, 10진수로 읽도록 지정한다.
  • skipws와 noskipws: skipws를 지정하면 토큰화할 떄 공백을 건너뛰고, noskpws를 지정하면 공백을 하나의 토큰으로 취급한다. 기본적으로 skipws로 지정되어 있다.
  • ws: 스트림의 현재 위치부터 연달아 나온 공백 문자를 건너뛴다.
  • get_money: 스트림에서 화폐 금액을 표현한 값을 읽는 매개변수 방식의 매니퓰레이터
  • get_time: 스트림에서 일정한 형식으로 표현된 시각 정보를 읽는 매개변수 방식의 매니퓰레이터다.
  • quoted: 인용부호(따옴표)로 묶은 스트링을 읽는 매니퓰레이터로서 인수를 받는다. 이스케이프 문자로 입력된 따옴표는 스트링에 포함된다.

입력은 로케일 설정에 영향을 받는다.

(이하 설명 생략)

객체에 대한 입력과 출력

string은 C++ 언어의 기본 타입은 아니지만 << 연산자로 출력할 수 있다. C++에서는 객체가 입력되거나 출력되는 방식을 정의할 수 있다. << 나 >> 를 오버로딩하면 이 연산자가 특정한 타입이나 클래스를 처리하게 만들 수 있다.

(예시 생략)

스트링 스트림

스트링 스트림이란 string에 스트림 개념을 추가한 것이다. 이렇게 하면 텍스트 데이터를 메모리에서 스트림 형태로 표현하는 인메모리 스트림(in-memory stream)을 만들 수 있다.

예컨대 GUI 애플리케이션에서 콘솔이나 파일이 아닌 스트림으로부터 텍스트 데이터를 구성한 뒤 이를 메시지 박스나 편집 컨트롤과 같은 GUI 요소로 결과를 출력할 수 있다. 또 다른 예로 스트링 스트림을 현재 위치에 대한 정보와 함께 여러 함수에 전달해서 다양한 작업을 연속적으로 처리할 수 있다. 스트링 스트림은 기본적으로 토큰화(tokenizing) 기능을 제공하기 때문에 텍스트 구문 분석(파싱, parsing) 작업에 활용해도 편하다.

string에 데이터를 쓸 때는 std::ostringstream 클래스를, 반대로 string에서 데이터를 읽을 때는 std::istringstream 클래스를 사용한다. 둘 다 <sstream> 헤더 파일에 정의돼 있다.

다음 코드는 사용자로부터 받은 단어들을 탭 문자로 구분해서 ostringstream에 쓴다. 다 쓰고 나면 str() 메서드를 이용하여 스트림 전체를 string 객체로 변환한 뒤 콘솔에 쓴다. 입력값 ‘done’이란 단어를 입력할 때까지 토큰 단위로 입력 받거나, 유닉스라면 Ctrl+D, 윈도우라면 Ctrl+Z를 입력해서 입력 스트림을 닫기 전까지 입력 받는다.

cout << "Enter tokens. Control+D (Unix) or Control+Z (Windows) to end" << endl;
ostringstream outStream;

while(cin)
{
string nextToken;
cout << "Next token: ";
cin >> nextToken;

if (!cin || nextToken == "done")
{
break;
}

outStream << nextToken << "\t";
}

cout << "The end result is: " << outStream.str();

스트링 스트림에서 데이터를 읽는 방법도 비슷하다. 다음 함수는 스트링 입력 스트림으로부터 Muffin 객체를 생성한 뒤 속성을 설정한다. 이때 받은 스트림 데이터는 일정한 포맷을 따르기 떄문에 이 함수는 Muffin 세터를 호출하는 방식으로 입력된 값을 간단히 변환할 수 있다.

Muffin createMuffin(istringstream& stream)
{
Muffin muffin;

// 데이터가 다음과 같은 형식에 맞게 들어온다고 가정한다. Description, size, chips

string description;
int size;
bool hasChips;

// 세 값 모두 읽는다. 이때 chips는 'true'나 'false'란 스트링으로 표현한다.
stream >> descrption >> size >> boolalpha >> hasChips;

if (stream)
{
muffin.setSize(size);
muffin.setDescription(description);
muffin.setHasChocolateChips(hasChips);
}
return muffin;
}

Note) 객체를 스트링처럼 일렬로 나열하는 것을 마셜링(marshalling)이라 부른다. 마셜링은 객체를 디스크에 저장하거나 네트워크로 전송할 때 유용하다.

파일 스트림

파일은 스트림 개념과 정확히 일치한다. 파일을 읽고 쓸 때 항상 현재 위치를 추적하기 때문이다. C++는 파일 출력과 입력을 위해 std::ofstream과 std::ifstream 클래스를 제공한다. 둘 다 <fstream> 헤더 파일에 정의돼 있다.

파일시스템을 다룰 때는 에러 처리가 특히 중요하다. 네트워크로 연결된 저장소에 있던 파일을 다루던 중 갑자기 네트워크 연결이 끊길 수 있고, 로컬 디스크에 파일을 쓰다가 디스크가 가득 찰 수도 있다. 또는 현재 사용자에게 권한이 없는 파일을 열 수도 있다. 이런 에러 상황을 제때 감지해서 적절히 처리하려면 표준 에러 처리 메커니즘을 이용하면 된다.

파일 출력 스트림과 다른 출력 스트림의 가장 큰 차이점은 파일 스트림 생성자는 파일의 이름과 파일을 열 때 적용할 모드에 대한 인수를 받는다는 점이다. 출력 스트림의 디폴트 모드는 파일을 시작 지점부터 쓰는 ios_base::out이다. 이때 기존 데이터가 있으면 덮어쓴다. 또는 파일 스트림 생성자의 두 번째 인수로 ios_base::app(추가모드)를 지정하면 파일 스트림을 기존 데이터 뒤에 추가할 수 있다. 파일 스트림의 모드로 지정할 수 있는 값은 다음과 같다.

상수 설명
ios_base::app 파일을 열고, 쓰기 연산을 수행하기 전에 파일 끝으로 간다.
ios_base::ate 파일을 열고 즉시 파일 끝으로 간다.
ios_base::binary 입력 또는 출력을 텍스트가 아닌 바이너리 모드로 처리한다.
ios_base::in 입력할 파일을 열고 시작 지점부터 읽는다.
ios_base::out 출력할 파일을 열고 시작 지점부터 쓴다. 기존 데이터를 덮어쓴다.
ios_base::trunc 출력할 파일을 열고 기존 데이터를 모두 삭제한다.(truncate)

여기 나온 모드를 조합해서 지정할 수도 있다.

(이하 설명 생략)

텍스트 모드와 바이너리 모드

파일 스트림은 기본적으로 텍스트 모드로 연다. 파일 스트림을 생성할 때 ios_base::binary 플래그를 지정하면 파일을 바이너리 모드로 연다.

바이너리 모드로 열면 정확히 바이트 단위로 지정한 만큼만 파일에 쓴다. 파일을 읽을 때는 파일에서 읽은 바이트 수를 리턴한다.

텍스트 모드로 열면 파일에서 \n이 나올 때마다 한 줄씩 읽거나 쓴다. 이떄 파일에서 줄 끝(EOL)을 나타내는 문자는 OS마다 다르다.

seek()과 tell() 메서드로 랜덤 액세스하기

입력과 출력 스트림은 모두 seek()와 tell() 메서드를 갖고 있다.

seek() 메서드는 입력 또는 출력 스트림에서 혀냊 위치를 원하는 지점으로 옮긴다. seek()은 여러 버전이 있다. 입력 스트림에 대한 seek() 메서드를 seekg() 라 부른다. 여기서 g는 ‘get’을 의미한다. 출력 스트림에 대한 seek()는 seekp()라 부르는데, 여기서 p는 ‘put’을 의미한다. 

seek()를 하나로 표현하지 않고 seekg(), seekp()로 구분한 이유는 파일 스트림처럼 입력과 출력을 모두 가질 때가 있기 때문이다. 이럴 때는 읽는 위치와 쓰는 위치를 별도로 관리해야 한다. 이를 양방향(bidrectional) I/O라 부른다.

seekg()와 seekp()는 각각 두 가지 버전이 있다. 하나는 절대 위치를 나타내는 인수 하나만 받아서 그 위치로 이동한다. 다른 하나는 오프셋(offset)과 위치에 대한 인수를 받아서 지정한 위치를 기준으로 떨어진 거리(오프셋)로 이동한다. 이때 위치는 std::streampos로 오프셋은 std::streamoff로 표현한다. C++ 에 미리 정의된 위치는 다음과 같다.

위치 설명
ios_base::beg 스트림의 시작점
ios_base::end 스트림의 끝점
ios_base::cur 스트림의 현재 위치

예컨대 다음과 같이 매개변수가 하나인 seekp()에 ios_base::beg 상수를 지정하면 출력 스트림의 위치를 절대 위치로 지정할 수 있다.

outStream.seekp(ios_base::beg);

입력 스트림의 위치를 지정하는 방법도 seekp()가 아닌 seekg()라는 점만 빼면 같다.

inStream.seekg(ios_base::beg);

인수가 두 개인 버전은 스트림의 위치를 상대적으로 지정한다. 첫 번째 인수는 이동할 위치의 양을 지정하고, 두 번째 인수는 시작점을 지정한다. 파일의 시작점을 기준으로 위치를 이동하려면 ios_base::beg 상수를 지정한다. ios_base::end를 사용하면 파일의 끝점을 기준으로 위치를 이동할 수 있다. 또한 현재 위치를 기준으로 이동하고 싶다면 ios_base::cur를 사용한다. 예컨대 다음 코드는 스트림의 시작점에서 두 바이트만큼 이동한다. 여기서 인수로 지정한 정숫값은 자동으로 streampos나 streamoff로 변환된다.

outStream.seekp(2, ios_base::beg);

다음 코드는 입력 스트림의 끝에서 세 번째 바이트로 이동한다.

inStream.seekg(-3, ios_base::end);

tell() 메서드를 이용하면 스트림의 현재 위치를 알아낼 수 있다. 이 메서드는 현재 위치를 streampos 타입의 값으로 리턴한다. seek()을 호출하거나 tell()을 다시 호출하기 전에 현재 위치를 기억하고 싶다면 앞서 tell()에서 리턴한 값을 저장해 둔다. seek()와 마찬가지로 tell()도 입력과 출력에 대해 서로 다른 버전(tellg(), tellp())를 제공한다.

다음 코드는 입력 스트림의 위치가 스트림의 시작점인지 확인한다.

std::streampos curPos = inStream.tellg();

if (ios_base::beg == curPos)
{
cout << "We're at the beginning" << endl;
}

(예시 코드 생략)

스트림끼리 서로 연결하기

입력 스트림과 출력 스트림은 언제든지 접근할 떄 내보내기(flush-on-access) 방식으로 서로 연결될 수 있다. 다시 말해 입력 스트림을 출력 스트림에 연결한 뒤 입력 스트림에서 데이터를 읽으면 즉시 출력 스트림으로 내보낸다. 이러한 동작은 모든 종류의 스트림에서 가능하며 파일 스트림끼리 연결할 때 특히 유용하다.

스트림을 연결하는 작업은 tie() 메서드로 처리한다. 출력 스트림을 입력 스트림에 연결하려면 입력 스트림에 대해 tie()를 호출한다. 이때 연결할 출력 스트림의 주소를 인수로 전달한다. 연결을 끊으려면 tie()에 nullptr를 전달해서 호출한다.

다음 코드는 한 파일에 대한 입력 스트림을 전혀 다른 파일에 대한 출력 스트림에 연결하는 예를 보여준다. 이때 같은 파일에 대한 출력 스트림을 연결해도 되지만, 이렇게 같은 파일에 읽고 쓸 때는 양방향 I/O를 이용하는 것이 낫다.

ifstream inFile("input.txt");
ofstream outFile("output.txt");

// inFile과 outFile을 연결한다.
inFile.tie(&outFile);

// outFile에 텍스트를 쓴다. std::endl이 입력되기 전까지는 내보내지 않는다.
outFile << "Hello there!";

// outFile을 아직 내보내지 않은 상태다.
// inFile에서 텍스트를 읽는다. 그러면 outFile에 대해 flush()가 호출된다.
string nextToken;
inFile >> nextToken;

// 이제 outFile이 내보내졌다.

여기서 사용한 flush() 메서드는 ostream 베이스 클래스에 정의돼 있다. 따라서 다음과 같이 출력 스트림을 다른 출력 스트림에 연결할 수도 있다.

outFile.tie(&anotherOutputFile);

이렇게 하면 한 파일에 뭔가 쓸 때마다 버퍼에 저장된 데이터를 다른 파일에 내보낸다. 이렇게 하면 서로 관련된 두 파일을 동기화시킬 수 있다.

스트림 연결의 대표적인 예로 cout과 cin을 연결해서 cin에 데이터를 입력할 때마다 cout을 자동으로 내보내게 만드는 경우가 있다. cerr와 cout도 서로 연결할 수 있다. 반면 clog 스트림은 cout에 연결될 수 없다. 와이드 문자 버전의 스트림도 같은 방식으로 연결한다.

양방향 I/O

지금까지 살펴본 입력과 출력 스트림은 기능상 서로 관련이 있지만 별도의 클래스로 존재한다. 이와 달리 입력과 출력을 모두 처리하는 스트림도 있는데, 이를 양방향 스트림(bidirectional stream)이라 한다.

양방향 스트림은 iostream을 상속한다. 다시 말해 istream과 ostream을 동시에 상속하기 때문에 다중 상속의 대표적인 예이기도 하다. 양방향 스트림은 입력과 출력 스트림의 메서드 뿐만 아니라 >>와 << 연산자를 동시에 제공한다.

fstream 클래스는 양방향 파일시스템을 표현한다. fstream은 파일 안에서 데이터를 교체할 때 유용하다. 정확한 위치를 발견할 때까지 데이터를 읽다가 필요한 시점에 즉시 쓰기 모드로 전환할 수 있기 때문이다. 예컨대 ID와 전화번호 매핑 정보를 관리하는 프로그램을 보자. 이때 데이터는 다음과 같은 포맷으로 파일에 저장된다고 가정한다.

123 408-555-0394
124 415-555-3422
263 585-555-3490
100 650-555-3434

파일을 열고 데이터 전체를 읽고 나서 적절히 내용을 수정한 뒤 프로그램을 종료하기 전에 파일 전체를 다시 쓰는 방식으로 구현하는 경우가 많다. 그런데 데이터 양이 엄청나게 많다면 모든 내용을 메모리에 담을 수 없다. iostream을 이용하면 이런 문제를 피할 수 있다. 팡리에서 데이터를 검색하다가 적절한 지점을 발견하면 추가 모드(append mode)로 열고 원하는 내용을 추가하면 된다. 

다음 예는 특정한 ID에 대한 전화번호를 변경하는데, 이렇게 기존 데이터를 수정할 떄는 양방향 스트림을 활용한다.

bool changeNumberForID(string_view filename, int id, string_view newNumber)
{
fstream ioData(filename.data());

if (!ioData)
{
cerr << "Error while opening file " << filename << endl;
return false;
}

// 파일 끝까지 반복한다.
while(ioData)
{
int idRead;
string number;

// 다음 ID를 읽는다.
ioData >> idRead;

if (!ioData)
{
break;
}

// 현재 레코드가 수정할 대상인지 확인한다.
if (idRead == id)
{
// 쓰기 위치를 현재 읽기 위치로 이동한다.
ioData.seekp(ioData.tellg());

// 한 칸 띄운 뒤 새 번호를 쓴다.
ioData << " " << newNumber;
break;
}

// 현재 위치에서 숫자를 읽어서 스트림의 위치를 다음 레코드로 이동한다.
ioData >> number;
}
return true;
}

물론 이 방법은 데이터의 크기가 일정할 때만 적용할 수 있다. 앞의 예제에서 읽기 모드를 쓰기 모드로 전환하는 순간 기존 파일에 있던 데이터를 덮어쓴다. 파일 포맷을 그대로 유지하면서 다음 레코드를 덮어쓰지 않게 하려면 데이터(레코드)의 크기가 모두 같아야 한다.

stringstream 클래스를 이용하면 스트링 스트림도 양방향 스트림처럼 다룰 수 있다.

Note) 양방향 스트림은 읽기 위치와 쓰기 위치에 대한 포인터를 별도로 사용한다. 읽기와 쓰기 모드를 전환할 때마다 seek() 메서드로 각각의 위치를 적절히 설정해야 한다.

 

 

 

 

 

전문가를 위한 C++/ 템플릿으로 제네릭 코드 만들기

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

템플릿 소개

절차형 프로그래밍 패러다임에서는 프로시저나 함수 단위로 프로그램을 작성한다. 그중에서 특히 함수를 많이 사용하는데, 알고리즘을 작성할 때 특정한 값에 의존하지 않게 구현해두면 나중에 임의의 값에 대해 얼마든지 재사용이 가능하기 때문이다. 이렇게 함수를 작성하는 것을 매개변수화 한다고 표현한다.

객체지향 프로그래밍 패러다임은 객체란 개념도 도입했는데, 객체란 데이터와 동작을 하나로 묶은 것으로 함수나 메서드에서 값을 매개변수화하는 방식과는 별개다.

템플릿은 매개변수화 개념을 더욱 발전시켜 값 뿐만 아니라 타입에 대해서도 매개변수화한다. C++에서 기본으로 제공하는 int, double 같은 기본 타입뿐만 아니라 SpreadsheetCell이나 CherryTree처럼 사용자가 정의한 클래스에 대해서도 매개변수화할 수 있다. 템플릿을 이용하면 주어진 값뿐만 아니라 그 값의 타입에 대해서도 독립적인 코드를 ㅈ가성할 수 있다.

템플릿이 제공하는 기능이 뛰어나지만 C++ 템플릿 문법이 상당히 복잡해서 템플릿을 직접 정의하지 않는 프로그래머가 많다.

(C#에서 제네릭과 유사하다)

클래스 템플릿

클래스 템플릿(class template)은 멤버 변수 타입, 메서드의 매개변수 또는 리턴 타입을 매개변수로 받아서 클래스를 만든다. 클래스 템플릿은 주로 객체를 저장하는 컨테이너나 데이터 구조에서 많이 사용한다. 

클래스 템플릿 작성법

템플릿 없이 구현한 Grid 클래스

(템플릿에 대한 내용이 주요하므로 그 외 부분에 대한 설명은 생략)

클래스 정의

class GamePiece
{
public:
virtual std::unique_ptr<GamePiece> clone() const = 0;
};

class ChessPiece : public GamePiece
{
public:
virtual std::unique_ptr<GamePiece> clone() const override;
};

std::unique_ptr<GamePiece> ChessPiece::clone() const
{
// 복제 생성자를 호출해서 이 인스턴스를 복제한다.
return std::make_unique<ChessPiece>(*this);
}

class GameBoard
{
public:
explicit GameBoard(size_t width = kDefaultWidth, size_t height = kDefaultHeight);
GameBoard(const GameBoard& src); // 복제 생성자
virtual ~GameBoard() = default; // 가상 디폴트 소멸자
GameBoard& operator=(const GameBoard& rhs); // 대입 연산자

// 이동 생성자와 대입 연산자를 명시적으로 디폴트로 지정한다.
GameBoard(GameBoard&& src) = default;
GameBoard& operator=(GameBoard&& src) = default;

std::unique_ptr<GamePiece>& at(size_t x, size_t y);
const std::unique_ptr<GamePiece>& at(size_t x, size_t y); const

size_t getHeight() const { return mHeight; }
size_t getWidth() const { return mWidth; }

static const size_t kDefaultWidth = 10;
static const sizt_t kDefaultHeight = 10;

friend void swap(GameBoard& first, GameBoard& second) noexcept;

private:
void verifyCoordinate(size_t x, sizt_y y) const;

std::vector<std::vector<std::unique_ptr<GamePiece>>> mCells;
size_t mWidth, mHeight;
};

메서드 정의

GameBoard::GameBoard(size_t width, size_t height)
: mWidth(width), mHeight(height)
{
mCells.resize(mWidth);

for (auto& column : mCells)
{
column.resize(mHeight);
}
}

GameBoard::GameBoard(const GameBoard& src)
: GameBoard(src.mWidth, src.mHeight)
{
// 여기 나온 생성자 이니셜라이저는 먼저 적절한 크기의 메모리를 할당하는 작업을 비복제 생성자에 위임한다.

// 그리고 나서 데이터를 복제한다.
for (size_t i = 0; i < mWidth; i++)
{
for (size_t j = 0; j < mHeight; j++)
{
if (src.mCells[i][j])
{
mCells[i][j] = src.mCells[i][j]->clone();
}
}
}
}

void GameBoard::verifyCoordinate(size_t x, size_t y) const
{
if (x >= mWidth || y >= mHeight)
{
throw std::out_of_range("");
}
}

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

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

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

GameBoard temp(this);
swap(*this, temp);
return *this;
}

const unique_ptr<GamePiece>& GameBoard::at(size_t x, size_t y) const
{
verifyCoordinate(x, y);
return mCells[x][y];
}

unique_ptr<GamePiece>& GameBoard::at(size_t x, size_t y)
{
return const_cast<unique_ptr<GamePiece>&>(as_const(*this).at(x, y));
}

사용 방법

GameBoard chessBoard(8, 8);
auto pawn = std::make_unique<ChessPiece>();
chessBoard.at(0, 0) = std::move(pawn);
chessBoard.at(0, 1) = std::make_unique<ChessPiece>();
chessBoard.at(0, 1) = nullptr;

템플릿으로 구현한 Grid 클래스

앞선 클래스 정의는 아쉬운 점이 몇 가지 있는데 다음과 같다.

첫째, GameBoard는 항상 원소를 포인터로 저장하기 때문에 원소를 값으로 저장할 수 없다.

둘째, 타입 안전성이 떨어진다. 이는 원소를 포인터로 저장하는 문제보다 심각하다. GameBoard는 각 셀을 unique_ptr<GamePiece>로 저장한다. ChessPiece로 저장했던 셀을 요청하기 위해 at()을 호출해도 unique_ptr<GamePiece>로만 리턴한다. 따라서 GamePiece를 ChessPiece로 다운캐스트해야 ChessPiece의 고유 기능을 활용할 수 있다.

셋째, int나 double 같은 기본 타입으로 저장할 수 없다. 셀은 GamePiece를 상속한 타입만 저장할 수 있기 때문에다.

따라서 ChessPiece나 SpreadsheetCell 뿐만 아니라 int, double 같은 타입도 모두 수용하려면 Grid를 제네릭 클래스로 만드는 것이 훨씬 좋다. 이럴 때는 C++에서 제공하는 클래스 템플릿을 이용하여 특정한 타입에 종속되지 않게 클래스를 구현하면 된다. 그러면 클라이언트는 이 템플릿에 저마다 원하는 타입에 맞는 클래스를 인스턴스화해서 사용할 수 있다. 이런 방식을 제네릭 프로그래밍이라 부른다. 

제네릭 프로그래밍의 가장 큰 장점은 타입 안전성이다. 다형성을 이용하면 추상 베이스 클래스로 정의해야 하지만, 클래스 템플릿을 활용하면 클래스 안에 있는 메서드를 비롯한 멤버의 타입을 모두 구체적으로 정의할 수 있다.

예컨대 ChessPiece 뿐만 아니라 TiaTacToePiece도 지원한다고 가정하자.

class TicTacToePiece : public GamePiece
{
public:
virtual std::unique_ptr<GamePiece> clone() const override;
};

std::unique_ptr<GamePiece> TicTacToePiece::clone() const
{
// 복제 생성자를 호출해서 이 인스턴스를 복제한다.
return std::make_unique<TicTacToePiece>(*this);
}

앞서 본 것처럼 게임 보드를 다형성으로 구현하면 체스보드 객체에 ChessPiece 뿐만 아니라 TicTacToePiece마저 저장해 버릴 위험이 있다.

GameBoard chessBoard(8, 8);
chessBoard.at(0, 0) = std::make_unique<ChessPiece>();
chessBoard.at(0, 1) = std::make_unique<TicTacToePiece>();

이렇게 구현하면 저장할 시점에 말의 타입을 기억해 두지 않으면 나중에 at() 메서드로 저장된 셀을 가져올 때 정확한 타입으로 다운캐스트를 할 수 없다는 심각한 문제가 발생한다.

Grid 클래스 정의

GameBoard 클래스에서 템플릿 기반으로 만든 Grid 클래스를 정의하자. Grid 클래스에서는 int와 double 같은 타입도 지원해야 하기 때문에 앞서 GameBoard를 구현할 때처럼 다형성 기반의 포인터 전달 방식으로 구현하는 것보다 다형성을 사용하지 않고 값 전달 방식으로 구현하는 것이 유리하다.

하지만 한 가지 단점이 있는데, 포인터 전달 방식과 달리 값 전달 방식을 적용할 때는 셀에 항상 어떤 값이 들어 있어야 하기 때문에 완전히 빈 셀을 만들 수 없다. 이에 반해 포인터 기반으로 구현하면 nullptr로 초기화하는 방식으로 빈 셀을 만들 수 있다. 다행히 C++ 17부터 지원하는 std::optional 을 이용하면 값 전달 방식을 지원하는 동시에 빈 셀도 표현할 수 있다.

template <typename T>
class Grid
{
public:
explicit Grid(size_t width = kDefaultWidth, size_t height = kDefaultHeight);
virtual ~Grid() = default;

// 복제 생성자와 대입 연산자를 명시적으로 디폴트로 지정한다.
Grid(const Grid& src) = default;
Grid<T>& operator=(const Grid& rhs) = default;

// 이동 생성자와 대입 연산자를 명시적으로 디폴트로 지정한다.
Grid(Grid&& src) = default;
Grid<T>& operator=(Grid&& rhs) = default;

std::optional<T>& at(sizt_t x, size_t y);
const std::optional<T>& at(sizt_t x, size_t y); const;

size_t getHeight() const { return mHeight; }
size_t getWidth() const { return mWidth; }

static const size_t kDefaultWidth = 10;
static const size_t kDefaultHeight = 10;

private:
void verifyCoordinate(size_t x, size_t y) const;

std::vector<std::vector<std::optional<T>>> mCells;
size_t mWidth, mHeight;
};

위 코드를 한 줄 씩 살펴보자.

template <typename T>

첫 줄은 뒤에 나올 클래스 정의가 특정한 타입에 적용할 수 있는 템플릿이라고 선언한다. template과 typename은 모두 C++에 정의된 키워드다. 템플릿은 타입을 매개변수로 받는다(이를 매개변수화한다고 표현한다).

이는 함수가 값을 매개변수화하는 방식과 같다. 함수를 호출할 때 지정하는 인수를 매개변수 이름으로 표현하듯 템플릿에 적용할 타입도 템플릿의 매개변수 이름(앞 코드의 T)으로 표현한다.

템플릿 지정자는 문장 전체에 적용된다. 앞에 나온 코드에서는 클래스를 정의하는 코드 전체에 적용된다.

Note) 템플릿 매개변수를 typename 대신 class 키워드로 표기해도 되는데, 그래서 다른 코드나 책에서는 template <class T>라고 표기하는 사례가 있다.

앞서 본 GameBoard 클래스는 mCells란 데이터 멤버를 포인터 원소에 대한 vector의 vector로 구현했다. 그래서 복제 작업을 특수한 코드(복제 생성자와 복제 대입 연산자)로 처리해야 했다. 하지만 템플릿을 이용한 Grid 클래스는 mCells의 타입을 optional 값에 대한 vector의 vector로 정의한다. 그러면 복제 생성자와 대입 연산자를 컴파일러가 만들어주는데, 이렇게 기본으로 생성된 것으로도 충분하다.

하지만 8장에서 설명했듯이 사용자가 직접 소멸자를 정의하면 복제 생성자나 복제 대입자가 자동으로 생성되지 않기 때문에 Grid 클래스 템플릿에서 복제 생성자와 복제 대입 연산자가 자동 생성되도록 명시적으로 디폴트로 지정했다. 마찬가지로 이동 생성자와 이동 대입 연산자도 명싲거으로 디폴트로 선언했다. 복제 대입 연산자를 명시적으로 디폴트로 선언하는 문장은 다음과 같다.

Grid<T>& operator= (const Grid& rhs) = default;

이 문장을 보면 앞서 const GameBoard& 타입으로 선언했던 rhs 매개변수가 const Grid& 타입으로 변경된 것을 알 수 있다. 이 타입을 const Grid<T>& 로 표기해도 된다. 참고로 클래스 정의 코드 안에서 Grid라고만 적어도 컴파일러는 Grid<T>로 해석한다. 하지만 클래스 저으이 밖에서는 반드시 Grid<T>라고 적어야 한다.

클래스 템플릿을 작성할 때는 Grid가 클래스 이름처럼 보이지만 엄밀히 말해 Grid는 템플릿 이름이다. 실제 클래스를 가리킬 때는 int, SpreadsheetCell, ChessPiece 등과 같은 구체적인 타입으로 템플릿을 인스턴스화한 이름으로 표현해야 한다. 따라서 Grid 클래스 템플릿으로 인스턴스화한 실제 클래스를 가리킬 때는 Grid<T>로 표현해야 한다.

 std::optional<T>& at(sizt_t x, size_t y);
const std::optional<T>& at(sizt_t x, size_t y); const;

이제 mCells는 더는 포인터가 아닌 optional 타입의 값으로 저장한다. 따라서 at() 메서드의 리턴 타입을 unique_ptr가 아닌 optional<T>& 또는 const optional<T>&로 변경한다.

Grid 클래스 메서드 정의

Grid 템플릿에서 메서드를 정의할 때는 반드시 템플릿 지정자(template <typename T>)를 앞에 적어야 한다. 예컨대 생성자를 정의하는 코드는 다음과 같다.

template<typename T>
Grid<T>::Grid(size_t width, size_t height)
: mWidth(width), mHeight(height)
{
mCells.resize(mWidth);

for (auto& column : mCells)
{
column.resize(mHeight);
}
}

Note) 템플릿을 정의할 때는 반드시 메서드 구현 코드를 헤더 파일에 적어야 한다. 그래야 컴파일러가 템플릿 인스턴스를 생성하기 전에 메서드 정의를 포함한 클래스 정의 전체를 알 수 있기 때문이다. 참고로 이러한 제약을 우회하는 방법도 있는데 12.2.4 ‘템플릿 코드를 여러 파일로 나누기’에서 소개하겠다.

여기서 :: 기호 앞의 클래스 이름이 Grid가 아닌 Grid<T>인 점에 주목한다. 메서드나 static 데이터 멤버를 정의하는 코드는 반드시 클래스 이름을 Grid<T>와 같이 표기해야 한다. 생성자의 본문은 GameBoard 생성자와 동일하다.

나머지 메서드 정의 코드는 템플릿 지정자와 Grid<T>를 제외하면 GameBoard와 같다.

template<typename T>
void Grid<T>::verifyCoordinate(size_t x, size_t y) const
{
if (x >= mWidth || y >= mHeight)
{
throw std::out_of_range("");
}
}

template<typename T>
const std::optional<T>& Grid<T>::at(size_t x, size_t y) const
{
verifyCoordinate(x, y);
return mCells[x][y];
}

template<typename T>
std::optional<T>& Grid<T>::at(size_t x, size_t y)
{
return const_cast<std::optional<T>&>(std::as_const(*this).at(x, y));
}

Note) 클래스 템플릿 메서드의 구현 코드를 작성할 때 템플릿 타입 매개변수 T에 대해 디폴트값을 지정하려면 T()와 같이 작성해야 한다. T가 클래스 타입이면 T()는 이 클래스의 디폴트 생성자를 호출하고 T가 기본타입이면 T()는 0을 생성한다. 이렇게 표기하는 방식을 영 초기화 문법이라 부르는데, 구체적인 타입을 모르는 변수에 디폴트값을 지정하는데 유용하다.

Grid 템플릿 사용법

Grid 템플릿으로 Grid 객체를 생성할 때는 타입에 Grid 뿐만 아니라 Grid에 저장할 대상의 타입도 함께 지정해야 한다. 이렇게 클래스 템플릿에 특정한 타입을 지정해서 구체적인 클래스를 만드는 것을 템플릿 인스턴스화(template instantiation)라고 한다. 템플릿 인스턴스화는 다음과 같이 객체를 선언하는 과정에 적용할 수 있다.

Grid<int> myIntGrid;  // int 값을 저장할 Grid 객체 선언. 이때 생성자에 디폴트 인수를 적용한다.
Grid<double> myDoubleGrid(11, 11); // double 값에 대한 11x11 Grid 선언

myIntGrid.at(0, 0) = 10;
int x = myIntGrid.at(0, 0).value_or(0);

Grid<int> grid2(myIntGrid); // 복제생성자
Grid<int> anotherIntGrid;
anotherIntGrid = grid2; // 대입 연산자

여기 나온 myIntGrid, grid2, anotherIntGrid의 타입은 모두 Grid<int>다. 이렇게 만든 Grid 객체에 SpreadsheetCell이나 ChessPiece 객체를 저장하는 코드를 작성하면 컴파일 에러가 발생한다.

value_or()을 사용하는 부분을 주목할 필요가 있다. at() 메서드는 std::optional 레퍼런스를 리턴하는데, optional에 값이 없을 수도 있다. value_or() 메서드는 optional에 값이 있을 때는 그 값을 리턴하고, 없으면 value_or()에 전달한 인수를 리턴한다.

이때 타입을 지정하는 문법이 중요하다. 다음과 같이 작성하면 컴파일 에러가 발생한다.

Grid test;  // 컴파일 에러
Grid<> test;  // 컴파일 에러

컴파일러가 첫 번째 문장을 발견하면 ‘클래스 템플릿을 사용하려면 템플릿 인수 목록을 지정해야 한다’는 에러 메시지가 발생하고 두 번째 문장에 대해서는 ‘템플릿 인수의 수가 너무 적다’는 에러 메시지가 발생한다.

Grid 객체를 받는 함수나 메서드를 선언할 때도 Grid에 저장할 항목의 타입을 구체적으로 지정해야 한다.

void processIntGrid(Grid<int>& grid)
{
// 코드 생략
}

아니면 12.3 절 ‘함수 템플릿’에서 설명하는 함수 템플릿을 이용해서 Grid의 원소 타입을 매개변수로 표현한 함수로 작성한다.

Note) Grid<int>처럼 매번 Grid 타입에 대한 정식 표기법으로 작성하기 번거롭다면 다음과 같이 앨리어스로 간단히 표현할 수 있다.

using IntGrid = Grid<int>;

이렇게 선언한 뒤에는 다음과 같이 표현할 수 있다.

void processIntGrid(IntGrid& grid) { }

Grid 템플릿은 int 외의 타입 객체도 저장할 수 있다. 예컨대 다음과 같이 SpreadsheetCell 객체를 저장하도록 Grid를 인스턴스화 할 수 있다.

Grid<SpreadsheetCell> mySpreadsheet;
SpreadsheetCell myCell(1.234);
mySpreadsheet.at(3, 4) = myCell;

다음처럼 포인터 타입 객체도 저장할 수 있다.

Grid<const char*> myStringGrid;
myStringGrid.at(2, 2) = "hello";

심지어 다른 템플릿 타입을 지정할 수도 있다.

Grid<vector<int>> gridOfVectors;
vector<int> myVector{1, 2, 3, 4};
gridOfVectors.at(5, 6) = myVector;

Grid 템플릿 인스턴스화를 통해 객체를 힙에 동적으로 생성할 수도 있다.

auto myGridOnHeap = make_unique<Grid<int>>(2, 2);
myGridOnHeap->at(0, 0) = 10;
int x = myGridOnHeap->at(0, 0).value_or(0);

꺾쇠괄호

C++ 11 이전에는 이중꺾쇠괄호(>>)를 연산자로 취급해서 다음과 같이 작성해야 했다.

std::vector<std::vector<T> > mCells;

C++ 11 이상 버전에서는 꺾쇠괄호 사이에 공백을 넣어야 하는 번거로움이 해결되었으므로 다음과 같이 작성해도 된다.

std::vector<std::vector<T>> mCells;

컴파일러에서 템플릿을 처리하는 방식

컴파일러는 템플릿 메서드를 정의하는 코드를 발견하면 문법 검사만 하고 템플릿 코드를 실제로 컴파일하지 않는다. 템플릿 저으이만 보고서 그 안에서 어떤 타입을 사용할지 알 수 없기 때문이다. 다시 말해 x = y 란 문장에서 x 와 y의 타입을 모르면서 컴파일러가 코드를 생성할 수 없다.

컴파일러가 Grid<int> myIntGrid처럼 템플릿을 인스턴스화하는 코드를 발견하면 Grid 템플릿의 매개변수 T에 int를 대입해서 int 버전의 Grid 클래스를 생성한다. 또한 Grid<SpreadsheetCell> mySpreadsheet 처럼 다른 타입에 대한 템플릿 인스턴스화 코드를 발견하면 SpreadsheetCell 타입에 대한 Grid 클래스를 추가로 생성한다.

이처럼 템플릿 기능이 없을 떄 원소의 타입마다 일일이 클래스를 정의했어야 할 작업을 컴파일러가 대신 해주는 것이다. 템플릿 처리 과정은 복잡하지 않다. 단순 반복적인 ‘copy & paste’와 단어 바꾸기 작업을 자동화한 것에 불과하다. 

만약 클래스 템플릿을 정의하는 코드만 작성하고 특정한 타입에 대해 인스턴스를 만드는 코드를 작성하지 않으면 클래스 메서드를 정의하는 코드는 컴파일되지 않는다.

선택적 인스턴스화

컴파일러는 항상 제네릭 클래스에 있는 모든 가상 메서드에 대한 코드를 생성한다. 하지만 virtual 로 선언하지 않은 다른 메서드는 그중에서 특정 타입에 대해 호출하는 메서드만 컴파일한다. 이를 선택적 인스턴스화(selective instantiation)라 부른다. 앞서 나온 Grid 클래스 템플릿을 이용하는 코드를 main() 함수에서 다음과 같이 작성했다고 하자.

Grid<int> myIntGrid;
myIntGrid.at(0, 0) = 10;

그러면 컴파일러는 int 버전의 Grid에서 제로 인수 생성자, 소멸자, non-const at() 메서드만 컴파일한다. 복제 생성자나 대입 연산자, getHeight()에 대한 코드는 생성하지 않는다.

템플릿에 사용할 타입의 요건

타입에 독립적인 코드를 작성하려면 적용할 타입에 대해 어느 정도 고려해야 한다. 예컨대 Grid 템플릿을 작성할 떄 T에 지정한 타입의 원소는 언제든지 소멸될 수 있다는 점을 고려해야 한다. 앞서 본 Grid 템플릿을 구현할 떄는 고려할 사항이 별로 없지만, 템플릿의 타입 매개변수에 맞게 대입 연산자를 제공해야 한다면 고려할 사항이 많다.

어떤 템플릿을 인스턴스화할 때 그 템플릿에 있는 연산을 모두 지원하지 않으면 컴파일 에러가 발생한다. 그런데 이런 에러 메서지는 상당히 난해하기 표현되어 있다. 그런데 템플릿을 인스턴스화 할 타입이 그 템플릿에 정의된 모든 연산에 적용할 수 없다면 앞서 설명한 선택적 인스턴스화를 이용해서 그중 일부 메서드를 사용하게 만들면 된다.

템플릿 코드를 여러 파일로 나누기

일반적인 클래스는 헤더에 정의를 적고, 메서드는 소스파일에 적고, 객체를 생성할 때는 그 객체가 속한 클래스의 정의가 담긴 헤더 파일을 #include 문에 적어줘야 한다. 링커는 이 문장을 보고 해당 클래스의 메서드를 참조할 경로를 마련해 준다.

하지만 템플릿을 처리하는 과정은 좀 다른데, 말 그대로 ‘템플릿’ 이기 때문에 사용자가 지정한 타입에 대한 메서드를 사용하는 문장이 나올 때마다 컴파일러는 템플릿 정의와 메서드 정의 코드를 모두 볼 수 있어야 코드를 제대로 생성할 수 있다. 이렇게 두 코드를 연결하는 방법은 여러 가지가 있다.

헤더 파일에 템플릿 정의하기

메서드 정의 코드를 클래스 정의 코드가 있는 헤더 파일에 함께 적는 방법이 있다. 그러면 이 템플릿을 사용하는 소스 파일에 #include 문으로 헤더 파일만 불러오면 컴파일러는 클래스 정의와 메서드 정의를 모두 참조할 수 있다. 앞서 본 Grid 예제가 그렇게 처리된다.

또 다른 방법은 템플릿 메서드 정의 코드를 다른 헤더 파일에 적고 그 헤더 파일을 클래스 정의를 담은 헤더 팡리에서 #include 문으로 불러오는 것이다. 이때 메서드 정의가 담긴 헤더를 추가하는 #include 문은 반드시 클래스 정의 코드 뒤에 적어야 한다. 그렇지 않으면 컴파일 에러가 발생한다.

template <typename T>
class Grid
{
// 클래스 정의 코드 생략
};

#include "GridDefinitions.h"

이렇게 정의한 Grid 템플릿을 사용할 때는 Grid.h 헤더 파일만 인클루드하면 된다. 이처럼 메서드 정의와 클래스 정의를 두 헤더 파일에 나눠서 작성하면 클래스 저으이와 메서드 정의를 명확히 구분할 수 있다는 장점이 있다.

소스 파일에 템플릿 정의하기

헤더 파일에 메서드 구현 코드가 어색하면 기존처럼 메서드 저으이 코드를 소스 파일에 작성해도 된다. 그렇다 해도 템플릿을 사용하는 코드에서 메서드 정의 코드를 볼 수 있어야 하는 사실은 변하지 않는다. 이럴 떄는 클래스 템플릿 정의가 있는 헤더 파일에 메서드 구현 코드가 있는 소스 파일을 추가하는 #include 문을 작성하면 된다. 어색해 보이지만 C++에서 정식 지원하는 문법이다.

template <typename T>
class Grid
{
// 클래스 정의 코드 생략
};

#include "Grid.cpp"

이제 Grid.cpp 파일이 프로젝트 빌드 목록에 추가되지 않도록 주의한다. 이 파일은 추가하면 안 될 뿐 아니라 추가할 방법도 없고 따로 컴파일 할 수도 없다. 반드시 헤더 파일의 #include 문에 추가해야 한다.

메서드 구현 코드가 담긴 파일 이름은 마음대로 해도 되는데, 그래서 이 용도로 작성한 소스 파일의 확장자를 .inl로 표기(ex: Grid.inl) 하는 프로그래머도 있다.

클래스 템플릿의 인스턴스화 제한하기

클래스 템플릿을 특정한 타입에만 적용하게 만들고 싶다면 다음에 나온 테크닉을 적용한다. 예컨대 Grid 클래스를 int, double, vector<int>에 대해서만 인스턴스화 할 수 있게 만들고 싶다면 헤더 파일을 다음과 같이 작성한다.

template <typename T>
class Grid
{
// 클래스 정의 코드 생략
};

이 헤더 파일에는 메서드 정의도 없고, 마지막에 #include 문도 없다. 이렇게 작성하면 실제 메서드 정의 코드가 담긴 .cpp 파일을 프로젝트 빌드 목록에 추가해야 한다. .cpp 파일은 다음과 같이 작성한다.

#include "Grid.h"
#include <utility>

template <typename T>
Grid<T>::Grid(size_t width, size_t height)
: mWidth(width), mHeight(height)
{
mCells.resize(mWidth);

for (auto& column : mCells)
{
column.resize(mHeight);
}
}

// 나머지 메서드 정의 생략

그러고 나서 이 메서드를 사용할 수 있게 하려면 템플릿에서 허용하는 타입으로 명시적으로 인스턴스화 해둬야 한다. 예컨대 앞서 나온 .cpp 파일의 마지막에 다음과 같이 작성한다.

// 인스턴스화를 허용할 타입을 명시적으로 나열한다.
template class Grid<int>;
template class Grid<double>;
template class Grid<std::vector<int>>;

이렇게 명시적으로 인스턴스화해두면 여기에 나온 타입에 대해서만 빌드하기 때문에 앞에 나온 Grid 클래스 템플릿을 다른 타입으로 인스턴스화할 수 없게 된다.

Note) 이러한 명시적 클래스 템플릿 인스턴스화 기법을 적용하면 클래스 템플릿에 있는 메서드를 실제로 사용하지 않더라도 그 템플릿에 있는 모든 메서드를 컴파일한다.

템플릿 매개변수

앞서 본 Grid 템플릿은 템플릿 매개변수를 하나만 지정했다. 일반적으로 클래스 템플릿을 작성할 떄는 다음과 같이 꺾쇠괄호 안에 매개변수를 나열한다.

template <typename T>

여기서 매개변수 목록을 지정하는 방식은 함수나 메서드에서 매개변수 목록을 작성할 떄와 비슷하다. 함수나 메서드처럼 클래스 템플릿의 매개변수도 원하는 수만큼 지정할 수 있다. 이때 매개변수를 타입 대신 디폴트값으로 지정해도 된다.

비타입 템플릿 매개변수

비타입 매개변수(non-type parameter)란 int나 포인터처럼 함수나 메서드에서 흔히 사용하는 종류의 매개변수를 말한다. 하지만 정수 계열의 타입(char, int, long), 열거 타입, 포인터, 레퍼런스, std::nullptr_t 등 비타입 매개변수로 사용할 수 있다. 참고로 C++ 17부터 auto, auto&, auto* 등도 비타입 매개변수로 사용할 수 있게 됐는데, 이렇게 지정하면 구체적인 타입을 컴파일러가 알아서 지정한다.

앞서 본 Grid 클래스 템플릿 예에서 그리드의 높이와 너비를 생성자에서 지정하지 않고 비타입 템플릿 매개변수로 표현할 수 있다. 이렇게 생성자 대신 템플릿 목록에서 비타입 매개변수를 사용하면 코드를 컴파일하기 전에 값을 알 수 있다는 장점이 있다. 앞서 설명했듯이 컴파일러는 템플릿 메서드의 코드를 생성하기 전에 먼저 템플릿 매개변수를 대입한다. 따라서 코드에서 이차원 배열을 vector에 대한 vector가 아닌 기존 정적 배열로 작성하더라도 크기를 동적으로 조절할 수 있다. Grid 클래스 템플릿 코드를 이렇게 수정하면 다음과 같다.

template <typename T, size_t WIDTH, size_t HEIGHT>
class Grid
{
public:
Grid() = default;
virtual ~Grid() = default;

// 복제 생성자와 대입 연산자를 명시적으로 디폴트로 지정한다.
Grid(const Grid& src) = default;
Grid<T, WIDTH, HEIGHT>& operator=(const Grid& rhs) = default;

std::optional<T>& at(size_t x, size_t y);
const std::optional<T>& at(size_t x, size_t y) const;

size_t getHeight() const { return HEIGHT; }
size_t getWidth() const { return WIDTH; }

private:
void verifyCoordinate(size_t x, size_t y) const;

std:: optional<T> mCells[WIDTH][HEIGHT];
}

이번에는 이동 생성자와 이동 대입 연산자를 명시적으로 디폴트로 지정하지 않았다. C 스타일 배열은 어차피 이동 의미론은 지원하지 않기 때문이다.

여기서 템플릿 매개변수 목록으로 Grid에 저장할 객체의 타입, 그리드의 너비와 높이를 지정해야 한다. 메서드를 정의하는 코드는 다음과 같다.

template<typename T, size_t WIDTH, size_t HEIGHT>
void Grid<T, WIDTH, HEIGHT>::verifyCoordinate(size_t x, size_t y) const
{
if (x >= WIDTH || y >= HEIGHT)
{
throw std::out_of_range("");
}
}

template<typename T, size_t WIDTH, size_t HEIGHT>
const std::optional<T>& Grid<T, WIDTH, HEIGHT>::at(size_t x, size_t y) const
{
verifyCoordinate(x, y);
return mCells[x][y];
}

template<typename T, size_t WIDTH, size_t HEIGHT>
std::optional<T>& Grid<T, WIDTH, HEIGHT>::at(size_t x, size_t y)
{
return const_cast<std::optional<T>&>(std::as_const(*this).at(x, y));
}

이전에 Grid<T>로 표기했던 부분을 Grid<T, WIDTH, HEIGHT>와 같이 템플릿 매개변수를 세 개 받도록 변경했다. 이렇게 변경한 템플릿은 다음과 같이 사용한다.

Grid<int, 10, 10> myGrid;
Grid<int, 10, 10> anotherGrid;
myGrid.at(2, 3) = 42;
anotherGrid = myGrid;
cout << anotherGid.at(2, 3).value_or(0);

코드가 간결해졌지만 의도와 달리 제약사항이 더 많아졌다. 첫 번째 제약은 높이와 너비 값에 non-const 정수를 지정할 수 없다는 것이다. 다음과 같이 작성하면 컴파일 에러가 발생한다.

size_t height = 10;
Grid<int, 10, height> testGrid; // 컴파일 에러 발생

height를 상수로 정의하면 문제없이 컴파일 된다. 

리턴 타입을 정확히 지정한 constexpr 함수로 표현해도 된다. 예컨대 size_t 타입의 값을 리턴하는 constexpr 함수로 높이에 대한 템플릿 매개변수를 초기화할 수 있다.

constexpr size_t getHeight() { return 10; }
...
Grid<double, 2, getHeight()> myDoubleGrid;

두 번째 제약은 첫 번째보다 심각한데, 수정된 템플릿에서는 높이와 너비가 템플릿 매개변수라서 두 값이 그리드 타입의 일부가 된다. 다시 말해 Grid<int, 10, 10>과 Grid<int, 10, 11>은 서로 다른 타입이 되어서 서로 대입할 수도 없고, 함수나 메서드에 전달할 때도 호환되지 않는다.

타입 매개변수의 디폴트값

너비와 높이를 비타입 템플릿 매개변수로 지정할 떄도 앞에서 Grid<T> 클래스 생성자에서 했던 것처럼 디폴트 값을 지정할 수 있다. 템플릿 매개변수에 디폴트값을 지정하는 문법은 생성자와 비슷하다. 또한 타입 매개변수 T에도 디폴트 값을 지정할 수 있다.

template<typename T = int, size_t WIDTH = 10, size_t HEIGHT = 10>
class Grid
{
// 나머지 코드는 이전과 같다.
}

이때 메서드를 정의하는 코드에서는 템플릿 선언문에 T, WIDTH, HEIGHT의 디폴트값을 생략해도 된다. 예컨대 at() 메서드를 다음과 같이 구현할 수 있다.

template<typename T, size_t WIDTH, size_t HEIGHT>
const std::optional<T>& Grid<T, WIDTH, HEIGHT>::at(size_t x, size_t y) const
{
verifyCoordinate(x, y);
return mCells[x][y];
}

이렇게 작성하면 Grid를 인스턴스화하는 코드를 다음과 같이 다양하게 표현할 수 있다. 템플릿 매개변수를 모두 생략해도 되고, 원소의 타입만 지정해도 되고, 원소의 타입과 너비만 지정해도 되고, 원소의 타입과 너비와 높이를 모두 지정해도 된다.

Grid<> myIntGrid;
Grit<int> myGrid;
Grid<int, 5> anotherGrid;
Grid<int, 5, 5> aFourthGrid;

참고로 클래스 템플릿 매개변수를 모두 생략하더라도 꺾쇠괄호는 반드시 적어야 한다. 클래스 템플릿 매개변수의 디폴트 인수를 지정할 때는 함수나 메서드에 디폴트 인수를 지정할 때와 똑같은 제약사항이 적용된다. 다시 말해 매개변수 목록에서 오른쪽 끝에서 왼쪽 방향으로 중간에 건너뛰지 않고 디폴트 값을 지정해야 한다.

생성자에 대한 템플릿 매개변수 추론 과정

C++ 17부터 클래스 템플릿 생성자에 전달된 인수를 보고 템플릿 매개변수를 자동으로 추론하는 기능이 추가됐다. C++ 17 이전에는 항상 클래스 템플릿에 템플릿 매개변수를 명시적으로 지정해야 했다.

예컨대 표준라이브러리에는 <utility> 헤더에 정의된 std::pair란 클래스 템플릿이 있다. 자세한 내용은 17장에서 설명하고 여기서는 pair는 두 값을 하나로 묶어서 저장한다고만 알아두자. 이를 다음과 같이 템플릿 매개변수로 지정한다.

std::pair<int, double> pair1(1, 2.3);

C++는 템플릿 매개변수를 일일이 적는 번거로움을 덜기 위해 std::make_pair()라는 헬퍼 함수 템플릿을 제공한다. 함수 템플릿은 12.3절에서 자세히 설명한다. 여기서는 함수 템플릿은 항상 전달된 인수를 보고 템플릿 매개변수를 알아서 결정한다는 것만 알아두자. 따라서 make_pair()도 전달된 값을 보고 템플릿 타입 매개변수를 자동으로 알아낸다. 예컨대 다음과 같이 호출하면 컴파일러는 템플릿 매개변수가 pair<int, double> 이라고 유추한다.

auto pair2 = std::make_pair(1, 2.3);

C++ 17부터는 이런 헬퍼 함수 템플릿을 더는 사용할 필요가없다. 생성자에 전달된 인수를 보고 템플릿의 타입 매개변수를 자동으로 알아내기 때문이다. 따라서 pair 클래스 템플릿을 다음과 같이 간단히 작성해도 된다.

std::pair pair3(1, 2.3);

물론 클래스 템플릿의 모든 템플릿 매개변수에 디폴트값을 지정했거나 생성자에서 이 매개변수를 사용할 때만 자동으로 추론할 수 있다.

Note) std::unique_ptr과 shared_ptr는 방금 설명한 타입 추론 기능이 적용되지 않도록 끌 수 있다. std::unique_ptr나 shared_ptr의 생성자에 T*를 전달하면 컴파일러는 <T>나 <T[]> 중 하나를 선택해야 하는데, 잘못 결정하면 치명적인 결과가 발생할 수 있다. 따라서 unique_ptr과 shared_ptr는 각각 make_unique()와 make_shared()를 사용하도록 작성하는 것이 바람직하다.

사용자 정의 추론 방식

템플릿 매개변수를 추론하는 규칙을 사용자가 직접 정할 수도 있다. 고급 주제에 해당하므로 자세히 설명하지 않겠지만 이 기능이 얼마나 강력한지 맛보기 위해 예제를 살펴보겠다. 다음과 같이 작성된 SpreadsheetCell 클래스 템플릿이 있다고 하자.

template<typename T>
class SpreadsheetCell
{
public:
SpreadsheetCell(const T& t) : mContent(t) { }

const T& getContent() const { return mContent; }

private:
T mContent;
};

자동 템플릿 매개변수 추론 기능을 이용하면 std::string 타입에 대한 SpreadsheetCell을 생성하는 코드를 다음과 같이 작성할 수 있다.

std::string myString = "Hello World!";
SpreadsheetCell cell(myString);

이때 SpreadsheetCell 생성자에 스트링을 const char* 타입으로 전달하면 원래 의도와 달리 T의 타입을 const char*로 결정해버린다. 이럴 때는 다음과 같이 규칙을 직접 지정해서 생성자의 인수를 const char* 타입으로 전달할 떄 T를 std::string으로 추론하게 만든다.

SpreadsheetCell(const char*) -> SpreadsheetCell<std::string>;

이 문장은 반드시 클래스 정의 밖에 적어야 한다. 단, 네임스페이스는 SpreadsheetCell 클래스와 같아야 한다. 

기본 문법은 다음과 같다. 여기서 explicit 키워드는 생략해도 된다. 효과는 단일 매개변수 생성자에 대해 explicit을 지정할 때와 같다. 따라서 매개변수가 하나일 때만 적용할 수 있다.

explicit 템플릿_이름(매개변수_목록) -> 추론된_템플릿;

메서드 템플릿

클래스 뿐만 아니라 메서드도 템플릿화 할 수 있다. 이러한 메서드 템플릿은 클래스 템플릿 안에 정의해도 되고, 비템플릿 클래스 안에 정의해도 된다. 메서드를 템플릿으로 제공하면 한 메서드를 다양한 타입에 대한 버전으로 만들 수 있다. 메서드 템플릿은 클래스 템플릿에 복제 생성자와 대입 연산자를 정의할 때 특히 유용하다.

Caution) 가상 메서드와 소멸자는 메서드 템플릿으로 만들 수 없다.

앞서 정의한 원소 타입 하나만 템플릿 매개변수로 받는 버전의 Grid 템플릿을 다시 살펴보자. 이 템플릿은 int와 double을 비롯한 여러 타입에 대해 Grid를 인스턴스화 할 수 있다.

Grid<int> myIntGrid;
Grid<double> myDoubleGrid;

그런데 이렇게 만든 Grid<int>와 Grid<double>은 타입이 서로 다르다. Grid<double> 객체를 받는 함수는 Grid<int> 객체를 인수로 받을 수 없다. 그 이유는 Grid 템플릿에 대한 복제 생성자와 대입 연산자가 다음과 같이 정의됐기 때문이다.

Grid(const Grid& src);
Grid<T>& operator=(const Grid& rhs);

이를 정확히 표현하면 다음과 같다.

Grid(const Grid<T>& src);
Grid<T>& operator=(const Grid<T>& rhs);

복제 생성자인 Grid와 대입 연산자인 operator=는 모두 const Grid<T> 레퍼런스를 인수로 받는다. 그러므로 Grid<double>을 인스턴스화해서 Grid 복제 생성자와 operator=을 호출하면 컴파일러는 각각에 대한 프로토타입을 다음과 같이 생성한다.

Grid(const Grid<double>& src);
Grid<double>& operator=(const Grid<double>& rhs);

생성된 Grid<double> 클래스 코드를 보면 Grid<int>를 받는 생성자나 operator=이 없다. 다행히 이 문제를 해결할 방법이 있는데, Grid 클래스의 복제 생성자와 대입 연산자를 메서드 템플릿으로 만들면 서로 다른 타입을 처리할 수 있다. 이렇게 수정한 Grid 클래스 정의 코드는 다음과 같다.

template<typename T>
class Grid
{
public:
// 코드 생략

template<typename E>
Grid(const Grid<E>& src);

template<typename E>
Grid<T>& operator=(const Grid<E>& rhs);

void swap(Grid& other) noexcept;

// 코드 생략
};

먼저 템플릿 버전으로 수정한 복제 생성자부터 살펴보자.

template<typename E>
Grid(const Grid<E>& src);

템플릿 선언문에 E(element의 줄임말)라는 새로운 타입 이름을 지정했다. 클래스는 T라는 타입에 대해 템플릿화되고, 방금 수정한 복제 생성자는 T와는 다른 E라는 타입에 대해 템플릿화된다. 이렇게 두 타입에 대해 템플릿화함으로써 한 타입의 Grid 객체를 다른 타입의 Grid로 복제할 수 있다. 수정한 복제 생성자를 정의하는 코드는 다음과 같다.

template<typename T>
template<typename E>
Grid<T>::Grid(const Grid<E>& src)
: Grid(src.getWidth(), src.getHeight())
{
// 이 생성자의 생성자 이니셜라이저는 적절한 양의 메모리를 할당하는 작업을 비복제 생성자에 위임한다.

// 그러고 나서 데이터를 복제한다.
for (size_t i = 0; i < mWidth; i++)
{
for (size_t j = 0; j < mHeight; j++)
{
mCells[i][j] = src.at(i, j);
}
}
}

위 코드에서 볼 수 있듯이 클래스 템플릿 선언문(T 매개변수가 있는)을 먼저 적고, 그 뒤에 멤버 템플릿(E 매개변수를 가진)을 선언하는 문장을 따로 작성해야 한다. 두 문장을 합칠 수는 없다.

이렇게 생성자 정의 코드 앞에 템플릿 매개변수를 선언하는 문장을 하나 더 추가해야 할 뿐만 아니라 src의 원소에 접근할 때는 반드시 getWidth(), getHeight(), at()과 같은 public 접근자 메서드를 사용해야 한다. 복제할 원본 객체의 타입은 Grid<E>이고, 복제할 대상 객체의 타입은 Grid<T>이기 때문이다. 두 타입은 서로 다르기 때문에 반드시 public 메서드로 다뤄야 한다.

이렇게 템플릿화한 대입 연산자는 const Grid<E>& 타입의 인수를 받아서 Grid<T>& 타입을 리턴한다.

template<typename T>
template<typename E>
Grid<T>& Grid<T>::operator=(const Grid<E>& rhs)
{
// 자기 잣니을 대입할 경우에 대한 예최 처리를 할 필요가 없다. 이 버전의 대입 연산자는 T와 E가 서로 같으면 호출되지 않기 때문이다.

// 복제 후 맞바꾸기 구문
Grid<T> temp(rhs);
swap(temp);
return *this;
}

템플릿 버전의 대입 연산자에서는 자기 대입 여부를 검사할 필요가 없다. 같은 타입끼리 대입하는 연산은 템플릿화하지 않은 원래 버전의 operator=이 처리하기 때문이다.

템플릿 버전의 대입 연산자는 복제 후 맞바꾸기 구문으로 구현했는데, 아직 함수 템플릿을 소개하지 않았기 때문에 여기서는 friend 함수인 swap() 대신 swap() 메서드를 사용했다. 이렇게 하면 같은 타입의 Grid 객체만 맞바꿀 수 있다는 한계가 있다. 그래도 문제가 되지 않는데, 템플릿화한 대입 연산자는 먼저 템플릿화한 복제 생성자를 이용하여 Grid<E> 객체를 Grid<T> 객체(temp)로 변환하기 때문이다. 그러고 나서 swap() 메서드로 Grid<T> 타입인 temp를 this와 맞바꾼다. 물론 this의 타입도 Grid<T>다. 여기 나온 swap() 메서드를 정의하는 코드는 다음과 같다.

template<typename T>
void Grid<T>::swap(Grid<T>& other) noexcept
{
using std::swap;

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

비타입 매개변수를 사용하는 메서드 템플릿 

앞서 본 예제에서 WIDTH, HEIGHT를 정수 타입 템플릿 매개변수로 지정하면 높이와 너비가 타입의 일부가 돼버리는 심각한 문제가 있었다. 이렇게 하면 높이와 너비가 다른 그리드에 대입할 수 없다. 그런데 간혹 크기가 다른 그리드끼리 대입하거나 복제해야 할 경우가 있다. 이럴 때는 대상 객체를 원본 객체와 완전히 똑같이 만드는 대신 원본 배열의 높이와 너비 둘 다 대상 배열보다 작다면 서로 겹치는 부분만 복제하고 나머지 부분은 디폴트 값으로 채워 넣는 방식으로 구현할 수 있다. 

대입 연산자와 복제 생성자를 메서드 템플릿으로 만들면 이처럼 크기가 다른 그리드끼리 대입하거나 복제하게 만들 수 있다. 따라서 ㅋ르래스 정의를 다음과 같이 수정한다.

template <typename T, size_t WIDTH = 10, size_t HEIGHT = 10>
class Grid
{
public:
Grid() = default;
virtual ~Grid() = default;

// 복제 생성자와 대입 연산자를 명시적으로 디폴트로 지정한다.
Grid(const Grid& src) = default;
Grid<T, WIDTH, HEIGHT>& operator=(const Grid& rhs) = default;

template<typename E, size_t WIDTH2, size_t HEIGHT2>
Grid<const Grid<E, WIDTH2, HEIGHT2>& src);

template<typename E, size_t WIDTH2, size_t HEIGHT2>
Grid<T, WIDTH, HEIGHT>& operator=(const Grid<E, WIDTH2, HEIGHT2>& rhs);

void swap(Grid& other) noexcept;

std::optional<T>& at(size_t x, size_t y);
const std::optional<T>& at(size_t x, size_t y) const;

size_t getHeight() const { return HEIGHT; }
size_t getWidth() const { return WIDTH; }

private:
void verifyCoordinate(size_t x, size_t y) const;

std:: optional<T> mCells[WIDTH][HEIGHT];
}

이렇게 수정한 클래스 정의를 보면 복제 생성자와 대입 연산자에 대한 메서드 템플릿과 swap()이란 헬퍼 메서드를 갖고 있다. 참고로 템플릿 버전이 아닌 기존 복제 생성자와 대입 연산자를 명시적으로 디폴트로 지정했다(소멸자를 직접 정의했기 때문). 두 메서드는 단순히 mCells만 복제하거나 대입하는데, 서로 크기가 같은 그리드끼리 대입하거나 복제할 때는 이렇게 처리 해야 하기 때문이다.

템플릿화한 복제 생성자를 정의하는 코드는 다음과 같다.

template<typename T, size_t WIDTH, size_t HEIGHT>
template<typename E, size_t WIDTH2, size_t HEIGHT2>
Grid<T, WIDTH, HEIGHT>::Grid(const Grid<E, WIDTH2, HEIGHT2>& src)
{
for (size_t i = 0; i < WIDTH; i++)
{
for (size_t j = 0; j < HEIGHT; j++)
{
if (i < WIDTH2 && j < HEIGHT2)
{
mCells[i][j] = src.at(i, j);
}
else
{
mCells[i][j].reset();
}
}
}
}

이 복제 생성자는 src가 더 크더라도 x와 y축에서 각각 WIDTH, HEIGHT로 지정된 크기만큼만 원소를 복제한다. 두 축 중 어느 하나가 src보다 작다면 나머지 영역에 있는 std::optional 객체들은 reset() 메서드로 리셋된다.

swap()과 operator=의 구현 코드는 다음과 같다.

template<typename T, size_t WIDTH, size_t HEIGHT>
void Grid<T, WIDHT, HEIGHT>::swap(Grid<T, WIDTH, HEIGHT>& other) noexcept
{
using std::sawp;

swap(mCells, other.mCells);
}

template<typename T, size_t WIDTH, size_t HEIGHT>
template<typename E, size_t WIDTH2, size_t HEIGHT2>
Grid<T, WIDTH, HEIGHT>& Grid<T, WIDTH, HEIGHT>::operator=(const Grid<E, WIDTH2, HEIGHT2>& rhs)
{
// 자기 대입에 대한 예외 처리를 할 필요가 없다. T와 E가 서로 같을 때는 이 버전의 대입 연산자가 호출되지 않기 때문이다.

// 복제 후 맞바꾸기 구문
Grid<T, WIDTH, HEIGHT> temp(rhs);
swap(temp);
return *this;
}

클래스 템플릿의 특수화

특정한 타입에 대해 클래스 템플릿을 다른 방식으로 구현하게 만들 수도 있다. 예컨대 C 스타일 스트링은 const char*에 대해 Grid 동작이 맞지 않을 수 있다. Grid<const char*>로 인스턴스화하면 원소가 vector<vector<optional<const char*>>>에 저장된다. 그러면 복제 생성자와 대입 연산자에서 const char* 포인터 타입에 얕은 복제가 적용된다. const char* 스트링은 깊은 복제로 처리해야 한다.

이 문제를 쉽게 해결하는 방법은 const char*에 대해서만 다르게 처리하도록 구현하는 것이다. 다시 말해 메모리를 자동으로 관리하도록 C 스타일 스트링을 C++ string으로 변환해서 vector<vector<optional<string>>>에 저장하게 만든다.

이렇게 특정한 경우에 대해섬나 템플릿을 다르게 구현하는 것을 템플릿 특수화(template specialization)라 한다. 템플릿 특수화 문법이 좀 어색할 수 있다. 클래스 템플릿 특수화 코드를 작성할 때는 이 코드가 템플릿이라는 사실 뿐만 아니라 이 템플릿이 특정한 타입에 특화된 버전이라는 것도 반드시 명시해야 한다. Grid를 const char*에 대해 특수화하는 방법은 다음과 같다.

// 템플릿 특수화를 적용할 때 원본 템플릿도 반드시 참조할 수 있어야 한다.
// 따라서 특수화한 템플릿과 함께 원본 템플릿도 항상 볼 수 있도록 include 문을 추가한다.
#include "Grid.h"

template<>
class Grid<const char*>
{
public:
explicit Grid(size_t width = kDefaultWidth, size_t height = kDefaultHeight);
virtual ~Grid() = default;

// 복제 생성자와 대입 연산자를 명시적으로 디폴트로 선언한다.
Grid(const Grid& src) = default;
Grid<const char*>& operator=(const Grid& rhs) = default;

// 이동 생성자와 대입 연산자를 명시적으로 디폴트로 선언한다.
Grid(Grid&& src) = default;
Grid<const char*>& operator=(Grid&& rhs) = default;

std::optional<std::string>& at(size_t x, size_t y);
const std::optional<std::string>& at(size_t x, size_t y) const;

size_t getHeight() const { return mHeight; }
size_t getWidth() const { return mWidth; }

static const size_t kDefaultWidth = 10;
static const size_t kDefaultHeight = 10;

private:
void verifyCoordinate(size_t x, size_t y) const;

std::vector<std::vector<std::optional<std::string>>> mCells;
size_t mWidth, mHeight;
};

이렇게 트굿화할 때는 T와 같은 타입 매개변수를 적지 않고 곧바로 const char*를 지정했다. 이렇게 해도 템플릿으로 취급하는 이유가 궁금할 수 있다. 다시 말해 다음과 같이 작성하면 뭐가 좋은지 궁금할 것이다.

template<>
class Grid<const char*>

이렇게 작성하면 컴파일러는 이 클래스가 const char*에 대한 특수화한 Grid라고 판단한다. 만일 다음과 같이 작성하면 컴파일 에러가 발생한다.

class Grid

Grid란 이름의 클래스(원본 클래스 템플릿)가 이미 있기 때문이다. 특수화할 때만 클래스 이름을 중복할 수 있다. 특수화의 대표적인 장점은 이렇게 특수화 됐다는 사실이 사용자에게 드러나지 않는다는 것이다. Grid를 int나 SpreadsheetCell에 대해 인스턴스화 하면 컴파일러는 원본 Grid 템플릿을 이용하여 코드를 생성한다. 하지만 const char*에 대한 Grid를 인스턴스화할 때는 const char*에 대한 특수화한 버전을 사용한다. 이 과정은 사용자에게 드러나지 않고 모두 내부적으로 처리된다.

Grid<int> myIntGrid;  // 원본 Grid 템플릿을 사용한다.
Grid<const char*> stringGrid1(2, 2); // const char* 에 대한 특수화 버전을 적용한다.

템플릿을 특수화하는 것은 상속(파생)과는 개념이 다르다. 특수화할 떄는 클래스 전체를 완전히 새로 구현해야 한다(어떤 코드도 상속하지 않는다) 따라서 상속할 때처럼 메서드 이름과 동작을 똑같이 정의할 필요가 없다. 사실 원본 클래스와 전혀 다르게 작성해도 되는데, 이는 템플릿 특수화 기능의 본래 목적과 다르게 남용하는 것이므로 특별한 이유가 없다면 이렇게 작성하지 않는게 좋다.

const char*에 대한 특수화 버전의 메서드는 다음과 같이 구현한다. 템플릿 정의 코드와 달리 메서드 앞에 template<> 구문을 적지 않아도 된다.

Grid<const char*>::Grid(size_t width, size_t height)
: mWidth(width), mHeight(height)
{
mCells.resize(mWidth);

for (auto& column : mCells)
{
column.resize(mHeight);
}
}

void Grid<const char*>::verifyCoordinate(size_t x, size_t y) const
{
if (x >= mWidth || y >= mHeight)
{
throw std::out_of_range("");
}
}

const std::optional<std::string>& Grid<const char*>::at(size_t x, size_t y) const
{
verifyCoordinate(x, y);
return mCells[x][y];
}

std::optional<std::string>& Grid<const char*>::at(size_t x, size_t y)
{
return const_cast<std::optional<std::string>&>(std::as_const(*this).at(x, y));
}

클래스 템플릿 상속하기

클래스 템플릿도 상속할 수 있다. 템플릿을 상속한 파생 클래스도 템플릿이어야 한다. 반면 클래스 템플릿을 특정한 타입으로 인스턴스화한 클래스를 상속할 때는 파생 클래스가 템플릿이 아니어도 된다. 두 경우 중 파생 클래스도 템플릿인 경우를 살펴보자.

#include "Grid.h"

template<typename T>
class GameBoard : public Grid<T>
{
public:
explicit GameBoard(size_t width = Grid<T>::kDefaultWidth, size_t height = Grid<T>::kDefaultHeight);
void move(size_t xsrc, size_t ySrc, size_t xDest, size_t yDest);
}

GameBoard 템플릿은 Grid 템플릿을 상속한다. 템플릿을 상속하는 구문은 베이스 클래스가 Grid가 아닌 Grid<T>라는 점만 빼면 기존 상속 구문과 차이가 없어 보인다. 사실 GameBoard 템플릿은 제네릭 템플릿인 Grid를 곧바로 상속하는 것이 아니라 GameBoard를 특정한 타입에 인스턴스화할 때마다 그 타입에 대해 Grid를 인스턴스화 클래스를 상속하는 것이다. 그래서 템플릿 상속 문법이 일반 클래스 상속과 같은 것이다. 예컨대 GameBoard를 ChessPiece 타입에 대해 인스턴스화 하면 컴파일러는 Grid<ChessPiece>에 대한 코드도 함께 생성한다.

이번에는 생성자와 move() 메서드를 구현하는 코드를 살펴보자. 생성자 이니셜라이저를 보면 베이스 클래스 생성자를 호출할 때 Grid<T>를 사용했다. 참고로 C++의 이름 조회 규칙에 따르면 베이스 클래스 템플릿의 데이터 멤버나 메서드를 가맄리 때 this 포인터나 Grid<T>::를 붙여야 하는데, 이를 강제하는 컴파일러는 많지 않다.

template<typename T>
GameBoard<T>::GameBoard(size_t width, size_t height)
:Grid<T>(width, height)
{
}

template<typename T>
void GameBoard<T>::move(size_t xSrc, size_t ySrc, size_t xDest, size_t yDest)
{
Grid<T>::at(xDest, yDest) = std::move(Grid<T>::at(xSrc, ySrc));
Grid<T>::at(xSrc, ySrc).reset();

// 이 부분을 다음과 같이 구현해도 된다.
// this->at(xDest, yDest) = std::move(this->at(xSrc, ySrc));
// this->at(xSrc, ySrc).reset();
}

상속과 특수화 비교

  상속 특수화
코드 재사용 O : 파생 클래스는 베이스 클래스에 있는 데이터 멤버와 메서드를 모두 가진다. X: 특수화를 할 때는 필요한 코드를 모두 다시 작성해야 한다.
이름 재사용 X: 파생 클래스의 이름은 반드시 베이스 클래스와 다르게 지어야 한다. O: 특수화 템플릿 클래스의 이름은 반드시 원본과 같아야 한다.
다형성 지원 O: 파생 클래스의 객체를 베이스 클래스의 객체로 표현할 수 있다. X: 템플릿을 인스턴스화한 결과마다 타입이 다르다.

앨리어스 템플릿

11장에서 소개한 타입 앨리어스(using)와 typedef의 개념을 이용하면 특정한 타입을 다른 이름으로 부를 수 있다. 클래스 템플릿에 대해서도 타입 앨리어스를 적용할 수 있다. 예컨대 다음과 같은 클래스 템플릿이 있다고 하자.

template<typename T1, typename T2>
class MyTemplateClass { /* ... */ };

이 클래스 템플릿의 타입 매개변수를 다음과 같이 지정한 인스턴스에 타입 앨리어스를 적용할 수 있다.

using OtherName = MyTemplateClass<int, double>;

여기서 타입 앨리어스 대신 typedef를 사용해도 된다.

또한 타입 매개변수 중에서 일부만 지정하고 나머지 타입은 그대로 템플릿 타입 매개변수 형태로 남겨둘 수 있다. 이를 앨리어스 템플릿(alias template)이라 부른다. 예컨대 다음과 같다.

template<typename T1>
using OtherName = MyTemplateClass<T1, double>;

이런 문장은 typedef로 표현할 수 없다.

함수 템플릿

메서드가 아닌 일반 함수도 템플릿화 할 수 있다. 예컨대 배열에서 값을 하나 찾아서 그 값의 인덱스를 리턴하는 제네릭 함수를 작성해 보자.

static const size_t NOT_FOUND = static_cast<size_t>(-1);

template<typename T>
size_t Find(const T& value, const T* arr, size_t size)
{
for (size_t i = 0; i < size, i++)
{
if (arr[i] == value)
{
return i;
}
}
return NOT_FOUND;
}

위와 같이 작성한 Find() 함수 템플릿은 모든 타입의 배열에 적용할 수 있다. 예컨대 이 템플릿으로 int 배열에 담긴 정숫값의 인덱스를 찾을 수 있고, SpreadsheetCell 배열에서 SpreadsheetCell을 찾을 수도 있다.

이 함수는 두 가지 방식으로 호출할 수 있다. 하나는 꺾쇠괄호 안에 타입 매개변수를 명시적으로 지정하는 것이고, 다른 하나는 주어진 인수를 바탕으로 컴파일러가 타입 매개변수를 알아서 추론하도록 타입을 생략하는 것이다. 예를 들면 다음과 같다.

int myInt = 3, intArray[] = { 1, 2, 3, 4 };
const size_t sizeIntArray = std::size(intArray);

size_t res;
res = Find(myInt, intArray, sizeIntArray); // 타입 추론을 통해 Find<int>를 호출한다.
res = Find<int>(myInt, intArray, sizeIntArray); // Find<int>를 명시적으로 호출한다.

if (res != NOT_FOUND)
{
cout << res << endl;
}
else
{
cout << "Not Found" << endl;
}

double myDouble = 5.6, doubleArray[] = { 1.2, 3.4, 5.7, 7.5 };
const size_t sizeDoubleArray = std::size(doubleArray);

// 타입 추론을 통해 Find<double>을 호출한다.
res = Find(myDouble, doubleArray, sizeDoubleArray);
// Find<double>을 명시적으로 호출한다.
res = Find<double>(myDouble, doubleArray, sizeDoubleArray);

if (res != NOT_FOUND)
{
cout << res << endl;
}
else
{
cout << "Not Found" << endl;
}

res = Find<double>(myInt, doubleArray, sizeDoubleArray);

SpreadsheetCell cell1(10), cellArray[] = {SpreadsheetCell(4), SpreadsheetCell(10)};
const size_t sizeCellArray = std::size(cellArray);

res = Find(cell1, cellArray, sizeCellArray);
res = Find<SpreadsheetCell>(cell1, cellArray, sizeCellArray);

앞서 구현한 Find() 함수는 배열의 크기를 매개변수 중 하나에 반드시 지정해야 한다. 간혹 컴파일러가 배열의 크기를 정확히 아는 경우가 있다. 대표적인 예로 스택 기반 배열을 사용할 때다. 이런 배열에 대해 Find()를 호출할 때 배열의 크기에 대한 인수를 생략할 수 있다면 편할 것이다.

이럴 때는 다음과 같이 함수 템플릿을 이용하면 된다. 이 코드는 Find()에 대한 호출을 단순히 이전 Find() 함수 템플릿으로 포워딩하기만 한다. 또한 코드에 나온 것처럼 함수 템플릿도 클래스 템플릿처럼 비타입 매개변수를 받게 만들 수 있다.

template<typename T, size_t N>
size_t Find(const T& value, const T(&arr)[N])
{
return Find(value, arr, N);
}

Find()를 사용하는 방법은 다음과 같다.

int myInt = 3, intArray[] = { 1, 2, 3, 4 };
size_t res = Find(myInt, intArray);

클래스 템플릿의 메서드 정의와 마찬가지로 함수 템플릿을 사용하는 코드는 이 템플릿의 프로토타입 뿐만 아니라 정의 코드도 접근할 수 있어야 한다. 따라서 함수 템플릿을 여러 소스 파일에서 사용한다면 함수 템플릿을 정의하는 코드를 헤더 파일에 넣어두거나 ‘소스 파일에 템플릿 정의하기’에서 설명한 것처럼 명시적으로 인스턴스화하는 것이 좋다.

함수 템플릿의 템플릿 매개변수도 클래스 템플릿처럼 디폴트 값을 지정할 수 있다.

함수 템플릿의 특수화

클래스 템플릿과 마찬가지로 함수 템플릿도 특수화할 수 있다. 예컨대 operator== 대신 strcmp()로 값을 비교하도록 C 스타일 스트링인 const char*에 특화된 Find() 함수를 만들 수 있다. 예컨대 다음과 같다.

template<>
size_t Find<const char*>(const char* const& value, const char* const* arr, size_t size)
{
for (size_t i = 0; i < size; i++)
{
if (strcmp(arr[i], value) == 0)
{
return i;
}
}
return NOT_FOUND;
}

이때 매개변수 타입을 인수로부터 추론할 수 있다면 함수 이름에서 <const char*>를 생략해도 된다. 따라서 함수 프로토타입을 다음과 같이 작성할 수 있다.

template<>
size_t Find(const char* const& value, const char* const* arr, size_t size)

그런데 다음 절에서 섦여할 오버로딩을 함께 적용하면 타입 추론 규칙이 복잡해질 수 있다. 따라서 실수를 방지하기 위해서라도 타입을 명시적으로 지정하는 것이 좋다.

특수화한 Find() 함수도 첫 번째 매개변수로 const char* const& 대신 const char* 만 받을 수 있짐나, 특수화되지 않은 버전의 Find()와 인수를 서로 일치시키는 것이 타입을 정확히 유추하는데 도움 된다.

이렇게 특수화한 버전의 Find()를 사용하는 방법은 다음과 같다.

const char* word = "two";
const char* words[] = { "one", "two", "three", "four" };
const size_t sizeWords = std::size(words);
size_t res;

// const char*에 대해 특수화된 버전을 호출한다.
res = Find<const char*>(word, words, sizeWords);

// const char*에 대해 특수화된 버전을 호출한다.
res = Find(word, words, sizeWords);

함수 템플릿 오버로딩

함수 템플릿도 일반 함수처럼 오버로딩 할 수 있다. 예컨대 Find()를 const char*에 대해 특수화하지 않고, const char* 스트링을 처리하는 일반 함수로 만들어도 된다.

size_t Find(const char* const& value, const char* const* arr, size_t size)
{
for (size_t i = 0; i < size; i++)
{
if (strcmp(arr[i], value) == 0)
{
return i;
}
}
return NOT_FOUND;
}

이렇게 해도 앞서 구혆나 템플릿 특수화 버전과 똑같이 실행되지만, 함수가 호출 될 때 적용되는 규칙은 좀 다르다.

const char* word = "two";
const char* words[] = { "one", "two", "three", "four" };
const size_t sizeWords = std::size(words);
size_t res;

// T=const char*인 템플릿 호출
res = Find<const char*>(word, words, sizeWords);

// 비템플릿(일반) Find() 호출
res = Find(word, words, sizeWords);

이처럼 const char* 타입이 명시적으로 지정될 때뿐만 아니라 타입 추론으로 결정될 때도 함수가 제대로 호출되게 하려면 일반 함수로 오버로딩하지 말고 템플릿 특수화를 이용해야 한다.

함수 템플릿 오버로딩과 특수화 동시에 사용하기

const char*에 대해 템플릿 특수화를 적용한 Find()와 const char*에 대해 일반 함수로 작성한 Find()를 동시에 사용할 수도 있다. 기본적으로 컴파일러는 항상 템플릿 버전보다 일반 함수 버전을 우선시 한다. 하지만 템플릿 인스턴스화를 명시적으로 지정하면 컴파일러는 무조건 템플릿 버전을 선택하나.

const char* word = "two";
const char* words[] = { "one", "two", "three", "four" };
const size_t sizeWords = std::size(words);
size_t res;

// const char*에 특수화된 템플릿 버전을 호출한다.
res = Find<const char*>(word, words, sizeWords);

// 일반 함수로 구현한 Find()를 호출한다.
res = Find(word, words, sizeWords);

클래스 템플릿의 friend 함수 템플릿

함수 템플릿은 클래스 템플릿에서 연산자를 오버로딩할 때 유용하다. 예컨대 Grid 클래스 템플릿에 덧셈 연산자를 오버로딩해서 두 그리드를 더하는 기능을 추가하고 싶을 수 있다. 덧셈의 결과로 나오는 Grid의 크기는 두 피연산자 중 작은 Grid의 크기에 맞춘다. 그리고 두 셀 모두 실제로 값이 들어 있을 때만 더한다.

그런 기능을 제공하는 operator+를 독립 함수 템플릿으로 만드는 경우를 생각해보자. 정의 코드는 다음과 같으며 Grid.h에 추가해야 한다.

template<typename T>
Grid<T> operator+(const Grid<T>& lhs, const Grid<T>& rhs)
{
size_t minWidth = std::min(lhs.getWidth(), rhs.getWidth());
size_t minHeight = std::min(lhs.getHeight(), rhs.getHeight());

Grid<T> result(minWidth, minHeight);

for (size_t y = 0; y < minHeight; ++y)
{
for (size_t x = 0; x < minWidth; ++x)
{
const auto& leftElement = lhs.mCells[x][y];
const auto& rightElement = rhs.mCells[x][y];

if(leftElement.has_value() && rightElemenet.has_value())
{
result.at(x, y) = leftElment.value() + rightElement.value();
}
}
}
return result;
}

이 함수 템플릿은 모든 타입의 Grid에 적용할 수 있다. 단 그리드에 저장할 원소의 타입이 덧셈 연산을 지원해야 한다. 이렇게 구현하면 Grid 클래스의 private 멤버인 mCells에 접근한다는 문제가 있다. 물론 public 메서드인 at()을 사용해도 되지만, 여기서는 함수 템플릿을 클래스 템플릿의 friend로 만드는 방법을 소개하겠다.

이를 위해 모든 덧셈 연산자를 Grid 클래스의 friend로 만든다. 그런데 Grid 클래스와 operator+가 모두 템플릿이다. 실제로 원하는 바는 operator+를 특정한 타입 T에 대해 인스턴스화한 것이 T 타입에 대한 Grid 템플릿 인스턴스의 friend가 되게 만드는 것이다. 이를 구현하면 다음과 같다.

//Grid 템플릿에 대한 전방 선언
template<typename T> class Grid;

// 템플릿화한 operator+에 대한 프로토타입
template<typename T>
Grid<T> operator+(const Grid<T>& lhs, const Grid<T>& rhs);

template<typename T>
class Grid
{
public:
// 코드 생략
friend Grid<T> operator+ <T>(const Grid<T>& lhs, const Grid<T>& rhs);
};

friend로 선언하는 과정은 좀 복잡하다. 이 템플릿을 T 타입으로 인스턴스화한 것에 대해 operator+를 T 타입으로 인스턴스화한 것이 friend가 돼야 한다. 다시 말해 클래스 인스턴스와 함수 인스턴스 사이의 friend 관계가 1:1 대응되게 해야 한다.

이때 operator+에 명시적으로 <T>를 지정한 부분이 중요하다. (참고로 operator+와 <T> 사이를 띄우지 않아도 된다) 이렇게 하면 컴파일러는 operator+를 템플릿으로 취급한다.

템플릿 매개변수 추론에 대한 보충 설명

컴파일러는 함수 템플릿에 전달된 인수를 보고 템플릿 매개변수의 타입을 추론한다. 추론할 수 없는 템플릿 매개변수는 반드시 명시적으로 지정해야 한다.

예컨대 다음 코드에 나온 add() 함수 템플릿은 템플릿 매개변수를 세 개 받는다.

template<typename RetType, typename T1, typename T2>
RetType add(const T1& t1, const T2& t2) { return t1 + t2; }

이렇게 작성한 함수 템플릿에 매개 변수 세 개를 모두 지정하는 예는 다음과 같다.

auto result = add<long long, int, int> (1, 2);

그런데 템플릿 매개변수인 T1과 T2는 이 함수의 매개변수이기 때문에 컴파일러는 T1과 T2의 타입을 추론한다. 그래서 add()를 호출할 때 리턴값에 대한 타입만 지정해도 된다.

auto result = add<long long>(1, 2);

물론 추론할 매개변수가 매개변수 목록의 마지막에 있을 때만 이렇게 할 수 있다. 예컨대 함수 템플릿에 다음과 같이 정의된 경우를 보자.

template<typename T1, typename RetType, typename T2>
RetType add(const T1& t1, const T2& t2) { return t1 + t2; }

이때 RetType을 반드시 지정해야 한다. 컴파일러는 이 타입을 추론할 수 없기 때문이다. 그런데 RetType이 두 번째에 있기 때문에 T1도 명시적으로 지정해야 한다.

auto result = add<int, long long>(1, 2);

리턴 타입에 대한 템플릿 매개변수에도 디폴트 값을 지정할 수 있다. 그러면 add()를 호출할 때 타입을 하나도 지정하지 않아도 된다.

template<typename RetType = long long, typename T1, typename T2>
RetType add(const T1& t1, const T2& t2) { return t1 + t2; }
...
auto result = add(1, 2)

함수 템플릿의 리턴 타입

add() 함수 템플릿에서 리턴값의 타입도 컴파일러가 추론해 주면 참 편할 것이다. 실제로 가능한데 리턴 타입은 템플릿 타입 매개변수에 따라 결정된다. 이 문제를 어떻게 해결할 수 있을까? 예컨대 다음과 같이 템플릿화된 함수가 있다고 하자.

template<typename T1, typename T2>
RetType add(const T1& t1, const T2& t2) { return t1 + t2; }

여기서 RetType은 반드시 t1 + t2 표현식의 타입으로 지정해야 한다. 그런데 T1과 T2를 모르기 때문에 이 표현식의 타입도 모른다. 1장에서 설명했듯이 C++ 14부터 컴파일러가 함수의 리턴 타입을 자동으로 추론하는 옵션이 추가되었기 때문에 add()를 다음과 같이 구현하면 된다.

template<typename T1, typename T2>
auto add(const T1& t1, const T2& t2)
{
return t1 + t2;
}

여기서 auto로 표현식의 타입을 추론하면 레퍼런스와 const 지정자가 사라진다. 반면 decltype은 이를 제거하지 않는다. add() 함수 템플릿을 좀 더 살펴보기 전에 먼저 auto와 decltype의 차이점부터 알아보자. 

예컨대 다음과 같이 템플릿이 아닌 일반 함수가 있다고 하자.

const std::string message = "Test";

const std::string& getString()
{
return message;
}

getString()을 호출한 결과를 auto 타입 변수에 저장할 수 있다.

auto s1 = getString();

auto에 의해 레퍼런스와 const 지시자가 사라지기 때문에 s1의 타입은 string이 되면서 복제 연산이 발생한다. const 레퍼런스를 사용하려면 다음과 같이 이 타입이 레퍼런스와 const라는 것을 명시적으로 지정해야 한다.

const auto& s2 = getString();

또 다른 방법은 decltype을 사용하는 것이다. decltype을 사용하면 const나 레퍼런스가 제거되지 않는다.

decltype(getString()) s3 = getString();

이렇게 하면 s3의 타입은 const string&가 된다. 그런데 getString()을 두 번이나 작성해서 코드 중복이 발생한다. 만약 getString() 대신 좀 더 복잡한 형태의 표현이라면 코드가 상당히 지저분해 진다.

이 문제는 다음과 같이 decltype(auto)로 해결할 수 있다.

decltype(auto) s4 = getString();

s4 역시 const string& 타입이다.

이제 auto와 decltype에 대해 알았으니 앞서 설명하던 add() 함수 템플릿으로 돌아가자 이번에는 add()에서 const와 레퍼런스 지시자가 사라지지 않도록 decltype(auto)로 지정한다.

template<typename T1, typename T2>
decltype(auto) add(const T1& t1, const T2& t2)
{
return t1 + t2;
}

C++ 14 이전에는, 다시 말해 함수의 리턴 타입 추론 기능과 decltype(auto)가 지원되기 전에는 이를 C++ 11부터 추가된 decltype(표현식) 구문으로 해결했다. 그러므로 예를 들어 다음과 같이 작성했을 것이다.

template<typename T1, typename T2>
decltype(t1 + t2) add(const T1& t1, const T2& t2) { return t1 + t2; }

하지만 이렇게 작성하면 안 된다. t1과 t2를 프로토타입의 시작 부분에 적었는데, 아직 t1과 t2의 타입을 모르기 때문이다. 컴파일러의 의미 분석기가 매개변수 목록을 끝까지 훑어본 후에야 t1과 t2의 타입을 정확히 알 수 있다.

이 문제는 대체 함수 구문(alternative function syntax)으로 해결할 수 있다. 여기서 리턴 타입을 매개변수 목록 뒤에 지정한다는 점에 주목한다(이를 후행 리턴 타입(trailing return type)이라 부른다). 그러면 매개변수의 이름과 각각의 타입 그리고 t1 + t2의 타입을 알 수 있다.

template<typename T1, typename T2>
auto add(const T1& t1, const T2& t2) -> decltype(t1+t2)
{
return t1 + t2;
}

하지만 최신 버전의 C++는 자동 리턴 타입 추론 기능과 decltype(auto)를 지원하기 때문에 대체 함수 구문 보다는 두 가지 기능 중 하나를 활용하는 것이 좋다.

변수 템플릿

C++ 14부터 클래스 템플릿, 메서드 템플릿, 함수 템플릿 외에 변수 템플릿도 지원한다. 문법은 다음과 같다.

template<typename T>
constexpr T pi = T(3.141592...);

이 코드는 파이 값에 대한 변수 템플릿으로 특정한 타입의 파이 변수를 생성하려면 다음과 같이 작성한다.

float piFloat = pi<float>
long double piLongDouble = pi<long double>;

그러면 지정한 타입으로 표현할 수 있는 범위에 맞게 파이값을 구할 수 있다. 다른 템플릿과 마찬가지로 변수 템플릿도 특수화할 수 있다.

전문가를 위한 C++/ C++의 까다롭고 유별난 부분

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

레퍼런스

C++에서 레퍼런스란 일종의 변수에 대한 앨리어스(alias, 별칭)이다. 레퍼런스를 이용해서 수정한 내용은 그 레퍼런스가 가리키는 변수의 값에 그대로 반영된다. 레퍼런스는 변수의 주소를 가져오거나 변수에 대한 역참조 연산을 수행하는 작업을 자동으로 처리해주는 특수한 포인터라고 볼 수 있다. 또는 변수에 대한 다른 이름(별칭)이라 생각해도 된다.

변수를 레퍼런스로 만들 수 있고, 클래스의 데이터 멤버를 레퍼런스로 만들 수 있고 함수나 메서드가 레퍼런스를 매개변수로 받거나 레퍼런스를 리턴하게 만들 수 있다.

레퍼런스 변수

레퍼런스 변수는 반드시 생성하자마자 초기화 해야 한다.

int x = 3;
int& xRef = x;

x 변수에 대한 대입문 바로 뒤에 나온 xRef는 x에 대한 또 다른 이름이다. xRef를 사용하는 것은 x를 사용하는 것과 같다. xRef에 어떤 값을 대입하면 x의 값도 바뀐다.

정수 리터럴처럼 이름 없는 값에 대해서는 레퍼런스를 생성할 수 없다. 단 const 값에 대해서는 레퍼런스를 생성할 수 있다.

int& unnamedRef1 = 5;  // 컴파일 에러 발생
const int& unnamedRef2 = 5; // 정상 작동

임시 객체도 마찬가지인데, 임시 객체에 대해 non-const 레퍼런스는 만들 수 있지만 const 레퍼런스는 얼마든지 만들 수 있다. 예컨대 다음과 같이 std::string 객체를 리턴하는 함수를 살펴보자.

std::string getString() { return "Hello world!"; }

getString()을 호출한 결과를 const 레퍼런스에 담을 수 있다. 그러면 이 레퍼런스가 스코프를 벗어나기 전까지 std::string 객체를 계속 가리킬 수 있다.

std::string& string1 = getString();  // 컴파일 에러 발생
const std::string& string2 = getString(); // 정상 작동

레퍼런스 대상 변경하기

레퍼런스는 처음 초기화할 때 지정한 변수만 가리킨다. 레퍼런스는 한 번 생성되고 나면 가리키는 대상을 바꿀 수 없다.

이 규칙 때문에 문법이 좀 헷갈리는데 레퍼런스를 선언할 때 어떤 변수를 ‘대입’ 하면 레퍼런스는 그 변수를 가리킨다. 하지만 이렇게 한 번 선언된 레퍼런스에 다른 변수를 대입하면 레퍼런스가 가리키는 대상이 바뀌는 것이 아니라 레퍼런스가 원래 가리키던 변수의 값이 새로 대입한 변수의 값으로 바뀌게 된다.

int x = 3, y = 4;
int& xRef = x;
xRef = y; //xRef가 y를 가리키는 것이 아니라 x의 값을 4로 변경한다.

여기서 y의 주소를 대입하면 가리키는 대상을 바꿀 수 있다고 생각할 수 있다.

xRef = &y;  // 컴파일 에러 발생

하지만 이렇게 작성하면 컴파일 에러가 발생한다. y의 주소는 포인터이지만 xRef는 포인터에 대한 레퍼런스가 아닌 int에 대한 레퍼런스이기 때문이다.

(이하 생략)

포인터에 대한 레퍼런스와 레퍼런스에 대한 포인터

레퍼런스는 모든 타입에 대해 만들 수 있는데, 심지어 포인터 타입을 가리키는 레퍼런스도 만들 수 있다. 예컨대 int 포인터를 가리키는 레퍼런스를 다음과 같이 만들 수 있다.

int* intP;
int*& ptrRef = intP;
ptrRef = new int;
*ptrRef = 5;

이렇게 *와 &가 연달아 붙은 표현이 생소하겠지만 의미는 간단하다. ptrRef는 intp에 대한 레퍼런스고, intP는 int에 대한 포인터다. ptrRef를 수정하면 intP가 바뀐다.

레퍼런스가 가져온 주소는 그 레퍼런스가 가리키는 변수의 주소와 같다.

int x = 3;
int& xRef = x;
int* xPtr = &xRef; // 레퍼런스의 주소는 값에 대한 포인터와 같다.
*Ptr = 100;

이 코드는 x에 대한 레퍼런스 주소를 가져와서 xPtr가 x를 가리키도록 설정한다. 그래서 *xPtr에 100을 대입하면 x의 값이 100으로 바뀐다.

그런데 ‘xPtr == xRef’라는 비교 연산을 수행하면 서로 타입이 다르다는 컴파일 에러가 발생한다. xPtr는 int에 대한 포인터 타입이고 xRef는 int에 대한 레퍼런스 타입이기 때문이다.. 따라서 ‘xPtr == &xRef’나 ‘xPtr == &x’와 같이 작성해야 한다.

마지막으로 주의해야 할 점은 레퍼런스에 대한 레퍼런스를 선언할 수 없다는 것이다. 예컨대 ‘int& &’나 ‘int&*’와 같이 선언할 수 없다.

레퍼런스 데이터 멤버

클래스의 데이터 멤버를 레퍼런스 타입으로 정의할 수 있다. 레퍼런스는 어떤 변수를 가리키지 않고서는 존재할 수 없다. 따라서 레퍼런스 데이터 멤버는 반드시 생성자의 본문이 아닌 생성자 이니셜라이저에서 초기화해야 한다.

class MyClass
{
public:
MyClass(int& ref) : mRef(ref) {}
private:
int& mRef;
}

레퍼런스 매개변수

레퍼런스 변수나 레퍼런스 데이터 멤버를 별도로 선언해서 사용하는 일은 많지 않다. 레퍼런스는 주로 함수나 메서드의 매개변수로 많이 사용한다.

(이하 설명 생략)

포인터를 레퍼런스로 전달하기

매개변수가 레퍼런스 타입인 함수나 메서드에 포인터를 전달하려면 좀 난감해진다. 이때는 포인터를 역참조해서 전달하면 포인터를 레퍼런스로 ‘변환’ 할 수 있다. 포인터가 가리키는 값을 가져와서 레퍼런스 매개변수를 초기화하기 때문이다. 예컨대 swap()을 호출할 때 포인터를 전달하려면 다음과 같이 작성한다.

int x = 5, y = 6;
int *xp = &x, *yp = &y;
swap(*xp, *yp);

레퍼런스 전달 방식과 값 전달 방식

레퍼런스 전달 방식은 함수나 메서드 안에서 인수로 전달한 값을 수정하고 싶을 때 주로 사용하는데, 이 경우 말고도 레퍼런스 전달 방식이 필요할 때가 있다. 레퍼런스로 전달하면 인수에 대한 복제본을 만들지 않기 때문에 다음과 같은 두 가지 장점이 있다.

  1. 효율성: 크기가 큰 객체나 stuct는 복제 오버헤드가 크다. 레퍼런스 전달 방식을 사용하면 객체나 struct에 대한 레퍼런스만 함수에 전달한다.
  2. 정확성: 값 전달 방식을 지원하지 않는 객체가 있다. 지원하더라도 깊은 복제가 적용되지 않을 수 있다.

이런 장점을 최대한 활용하고 원본 객체는 수정할 수 없게 만들려면 매개변수를 const 레퍼런스로 선언하면 된다.

레퍼런스 리턴값

함수나 메서드의 리턴값도 레퍼런스 타입으로 지정할 수 있다. 이렇게 하는 주된 이유는 효율성 때문이다. 객체 전체를 리턴하지 않고 객체에 대한 레퍼런스만 리턴하면 복제 연산을 줄일 수 있다. 물론 함수 종료 후에도 계속 남아 있는 객체에 대해서만 레퍼런스로 리턴할 수 있다.

rvalue 레퍼런스

(생략)

레퍼런스와 포인터의 선택 기준

레퍼런스로 할 수 있는 일을 포인터로 할 수 있기 때문에 C++에서 굳이 레퍼런스를 제공할 이유가 없다고 생각할 수도 있다. 예컨대 swap()을 다음과 같이 포인터로 구현해도 된다.

void swap(int* first, int* second)
{
int temp = *first;
*first = *second;
*second = temp;
}

하지만 이렇게 하면 코드가 복잡해진다. 레퍼런스를 사용하면 코드를 깔끔하게 읽기 쉽게 작성할 수 있고, 포인터보다 훨씬 안전하다. 레퍼런스의 값은 널이 될 수 없고, 레퍼런스를 명싲거으로 역참조 할 수도 없다. 그래서 포인터처럼 역참조 과정에서 에러가 발생할 가능성도 없다. 

단, 포인터가 하나도 없을 때만 레퍼런스가 더 안전하다고 할 수 있다. 예컨대 다음과 같이 int에 대한 레퍼런스를 인수로 받는 함수를 살펴보자.

void refcall(int& t) { ++t; }

임의의 메모리를 가리키도록 초기화한 포인터를 하나 만들자. 그리고 다음 코드처럼 이 포인터를 역참조해서 refcall()의 레퍼런스 타입 인수로 전달해 보자. 그러면 컴파일 에러는 발생하지 않지만 실행 과정에서 무슨 일이 벌어질지 예측할 수 없다. 실행하자마자 프로그램이 뻗어버릴 수 있다.

int* ptr = (int*)8;
refcall(*ptr);

포인터를 사용한 코드는 거의 대부분 레퍼런스로 바꿀 수 있다. 심지어 객체에 대한 레퍼런스는 객체에 대한 포인터처럼 다형성도 지원한다. 하지만 반드시 포인터를 사용해야 하는 경우가 있다. 대표적인 예로 가리키는 위치를 변경해야 할 때가 있다. 레퍼런스 타입 변수는 한 번 초기화되고 나면 그 변수가 가리키는 주솟값을 바꿀 수 없다. 예컨대 동적 할당 메모리의 주소는 레퍼런스가 아닌 포인터에 저장해야 한다. 또 다른 예로 주솟값이 nullptr가 될 수도 있는 optional 타입은 반드시 포인터를 사용해야 한다. 또한 컨테이너에 다형성 타입을 저장할 때도 포인터를 사용해야 한다.

매개변수나 리턴값을 포인터와 레퍼런스 중 어느 것으로 표현하는 것이 적합한지 판단하는 한 가지 방법은 메모리의 소유권이 어디에 있는지 따져보는 것이다. 메모리의 소유권이 변수를 받는 코드에 있으면 객체에 대한 메모리르 해제하는 책임은 그 코드에 있다. 따라서 객체를 포인터, 가능하면 스마트 포인터를 표현한다. 소유권을 이전할 필요가 있다면 항상 스마트 포인터를 사용하는 것이 좋다. 반면 메모리 소유권이 변수를 받는 코드에 없어서 메모리를 해제할 일이 없다면 레퍼런스로 전달한다.

Note) 포인터보다 레퍼런스를 사용하는 것이 좋다. 레퍼런스를 사용할 수 없을 때만 포인터를 사용한다.

(예시 코드 생략)

하지만 여기서 구현한 것처럼 결과를 매개변수로 전달하는 방식은 가급적 사용하지 않는 것이 좋다. 함수가 어떤 값을 리턴해야 한다면 출력 매개변수가 아닌 리턴문을 사용한다. C++ 11부터 추가된 이동 의미론을 적용하면 리턴해도 효율적으로 처리할 수 있다. 그리고 C++ 17에 도입된 구조적 바인딩을 사용하면 함수에서 여러 값을 리턴하는 과정을 정말 간단히 구현할 수 있다.

(예시 코드 생략)

(개인적으로 C++ 스타일의 코드를 처음 봤을 때 리턴값 없이 레퍼런스로 값을 변경하는 함수들이 많아서 놀랐는데, 역시나 저자도 리턴을 받는게 낫다고 한다)

키워드 혼동

C++에서 가장 헷갈리기 쉬운 키워드는 const와 static이다. 두 키워드는 다양한 의미로 활용되는데, 각각에 대한 미묘한 차이를 이해할 필요가 있다.

const 키워드

const는 상수를 의미하는 constant의 줄임말로 변경되면 안 될 대상을 선언할 때 사용한다. 컴파일러는 const로 지정한 대상을 변경하는 코드를 발견하면 에러를 발생시킨다. 또한 const로 지정된 대상을 최적화 할 때 효율을 더욱 높일 수 있다. 

이 키워드는 두 가지 대상에 적용할 수 있는데, 하나는 변수 또는 매개변수고 다른 하나는 메서드다.

const 변수와 매개변수

변수에 const를 붙이면 그 값이 변하지 않게 보호할 수 있다. 이 키워드는 #define으로 정의할 상수를 표현할 때 가장 많이 사용한다.

(이하 생략)

const 포인터

변수가 여러 단계의 간접 참조 연산을 거쳐야 하는 포인터로 선언 됐다면 const를 적용하기 까다롭다. 다음 코드를 보자.

int* ip;
ip = new int[10];
ip[4] = 5;

여기서 ip를 const로 지정하고 싶다고 하자. 이때 const로 지정할 대상이 ip 변수인지 아니면 이 변수가 가리키는 값인지 구별해야 한다.

포인터로 가리키는 값이 수정되지 않게 (세 번째 문장처럼 할 수 없게) 하려면 다음과 같이 const 키워드를 ip 변수의 포인터 타입 앞에 붙인다.

const int* ip;
ip = new int[10];
ip[4] = 5; // 컴파일 에러 발생

반면 변경하지 않게 하려는 대상이 ip가 가리키는 값이 아니라 ip 자체라면 다음과 같이 const를 ip 변수 앞에 붙인다.

int* const ip = nullptr;
ip = new int[10]; // 컴파일 에러 발생
ip[4] = 5;

이렇게 하면 ip 자체를 변경할 수 없기 때문에 이 변수를 선언과 동시에 초기화해야 한다.

물론 다음과 같이 ip와 ip 포인터가 가리키는 값을 모두 const로 지정할 수 있다.

int const* const ip = nullptr;

// 이렇게 작성해도 의미는 같다.
const int* const ip = nullptr;

문법이 좀 헷갈리지만 규칙은 간단하다. const 키워드는 항상 바로 왼쪽에 나온 대상에 적용된다. 위 문장에서 왼쪽에서 오른쪽으로 향할 때 첫 번째 const는 int의 바로 오른쪽에 있기 때문에 ip가 가리키는 int 값에 적용된다. 따라서 ip가 가리키는 값을 변경할 수 없게 된다. 두 번째 const는 * 연산자 오른쪽에 있기 때문에 앞에 나온 int를 가리키는 포인터, 즉 int 변수에 적용된다. 따라서 두 번째 const는 ip(포인터) 자체를 변경할 수 없게 만든다.

이 규칙이 헷갈리는 이유는 예외 문법이 있기 때문이다. 

const int* const ip = nullptr;

그런데 이런 예외 문법을 더 많이 사용한다. 방금 설명한 규칙은 포인터의 단계가 얼마든지 늘어나도 똑같이 적용된다.

const int* const* const* const ip = nullptr;
const 레퍼런스

레퍼런스에 const를 적용하는 것은 포인터에 const를 적용하는 것보다 간단하다. 그 이유는 두 가지인데, 첫째, 레퍼런스는 기본적으로 const 속성을 갖고 있다. 다시 말해 가리키는 대상을 변경하지 않는다. 그래서 명시적으로 const로 지정할 필요가 없다. 둘째, 레퍼런스에 대한 레퍼런스를 만들 수 없기 때문에 참조가 한 단계 뿐이다. 여러 단계로 참조하는 유일한 경우는 포인터에 대한 레퍼런스를 만들 때 뿐이다.

따라서 C++에서 ‘const 레퍼런스’라고 부르는 것은 대부분 다음과 같은 경우를 의미한다.

int z;
const int& zRef = z;
zRef = 4; // 컴파일 에러 발생

int&에 const를 지정하면 zRef에 다른 값을 대입할 수 없다. 포인터에 적용할 때와 마찬가지로 const int& zRef는 int const& zRef와 같다. 하지만 여기서 주의할 점은 zRef를 const라고 지정한 것과 z는 별개다. 그래서 zRef를 거치지 않고 z에 곧바로 접근하면 값을 변경할 수 있다.

const 레퍼런스는 주로 매개변수에 적용한다. 

(이하 생략)

const 메서드

클래스 메서드를 const로 지정할 수 있다. 그러면 그 클래스에서 mutable로 선언하지 않은 데이터 멤버는 변경할 수 없다.

constexpr 키워드

C++에서 제공하는 상수 표현식이라는 개념이 필요할 때가 있다. 예컨대 배열을 정의할 때 크기를 상수 표현식으로 지정해야 한다. 그러므로 다음과 같이 작성하면 에러가 발생한다.

const int getArraySize() { return 32; }

int main()
{
int myArray[getArraySize()]; // C++에서 허용하지 않는 표현
return 0;
}

constexpr 키워드를 사용하면 앞에 나온 getArraySize() 함수를 상수 표현식으로 다시 정의할 수 있다. 상수 표현식은 컴파일 시간에 계산된다.

constexpr int getArraySize() { return 32; }

int main()
{
int myArray[getArraySize()]; // OK
return 0;
}

함수에 constexpr을 적용하면 그 함수에 상당히 많은 제약사항이 적용된다. 컴파일러가 그 함수를 컴파일 시간에 평가해야 할 뿐만 아니라 함수에 부작용(side effect)이 발생하면 안 되기 때문이다.

(이하 생략)

static 키워드

static 데이터 멤버와 메서드

클래스의 데이터 멤버와 메서드를 static으로 선언할 수 있다. static으로 선언한 데이터 멤버는 static으로 지정하지 않은 멤버와 달리 객체에 속하지 않는다. 다시 말해 static 데이터 멤버는 객체 외부에 단 하나만 존재한다.

static 메서드도 객체가 아닌 클래스에 속한다는 점은 같다. static 메서드는 특정 객체를 통해 실행되지 않는다.

static 링크

static 링크에 대해 알아보기 전에 C++에서 링크를 처리하는 과정을 이해할 필요가 있다. C++는 코드를 소스 파일 단위로 컴파일해서 그 결과로 나온 오브젝트 파일들을 링크 단계에서 서로 연결한다. C++ 소스 파일(함수나 전역 변수 포함)마다 정의된 이름은 외부 링크나 내부 링크를 통해 서로 연결된다.

외부 링크(external linkage)로 연결되면 다른 소스 파일에서 이름을 사용할 수 있고, 내부 링크(internal linkage, 또는 정적 링크(static linkage))로 연결되면 같은 파일에서만 사용할 수 있다.

함수나 전역 변수는 기본적으로 외부 링크가 적용된다. 하지만 선언문 앞에 static 키워드를 붙이면 내부 링크(정적 링크)가 적용된다. 예컨대 FirstFile.cpp와 AnotherFile.cpp란 소스 파일이 있다고 하자. FirstFile.cpp는 다음과 같다.

void f();

int main()
{
f();
return 0;
}

이 파일은 f()의 프로토타입만 있고 (f()를 선언만 하고) 이를 정의하는 코드는 없다. AnotherFile.cpp는 다음과 같다.

void f();

void f()
{
std::cout << "f\n";
}

이 파일은 f()를 선언하는 코드와 정의하는 코드가 모두 있다. 이렇게 같은 함수에 대한 프로토타입을 여러 파일에 작성해도 된다. 원래 전처리기가 하는 일이 바로 이 작업이다. 전처리기는 소스 파일에 있는 #include 파일을 보고 헤더 파일에 나온 프로토타입을 소스 파일에 추가한다. 헤더 파일을 사용하는 이유는 프로토타입을 여러 파일에서 일관성 있게 유지하기 위해서다. 하지만 이 예제에서는 헤더 파일을 사용하지 않는다.

앞서 나온 소스 파일은 모두 아무런 에러 없이 컴파일되고 링크된다. f()가 외부 링크로 처리되어 main()에서 다른 파일에 있는 함수를 호출할 수 있기 때문이다.

이번에는 AnotherFile.cpp에서 f()의 선언문 앞에 static을 붙여 보자. 참고로 f()를 정의하는 코드에서는 static 키워드를 생략해도 된다. 단, 함수 선언문이 함수를 정의하는 코드보다 앞에 나와야 한다.

static void f();

void f()
{
  std::cout << "f\n";
}

이렇게 수정해도 컴파일 과정에는 아무런 에러가 발생하지 않지만 링크 단계에서 에러가 발생한다. f()를 static으로 지정해서 내부 링크로 변경돼 FirstFile.cpp에서 찾을 수 없기 때문이다. 어떤 컴파일러는 이렇게 메서드를 static으로 정의했지만 실제로 소스 파일에서 사용하지 않으면 메시지를 출력한다. (정의 코드만 있다는 것은 다른 파일에서 사용할 수도 있다는, 즉 static이 아니라는 것을 의미하기 때문이다)

static 대신 익명 네임스페이스(anonymous namespace)를 이용하여 내부 링크가 적용되게 할 수도 있다. 예컨대 다음과 같이 변수나 함수를 static으로 지정하지 말고 이름 없는 네임스페이스로 감싸면 된다.

namespace
{
void f();

void f()
{
std::cout << "f\n";
}
}

익명 네임스페이스에 속한 항목은 이를 선언한 소스 파일 안에서는 얼마든지 접근할 수 있지만 다른 소스파일에서는 접근할 수 없다. 그래서 static 키워드로 선언할 때와 효과가 같다.

extern 키워드

static과 관련된 키워드로 extern이 있다. 이름만 보면 static과 반대로 외부 링크를 지정할 때 사용하는 것 같다. 실제로 이렇게 사용하기도 한다. 예컨대 const와 typedef는 기본적으로 내부 링크로 처리된다. 여기에 extern을 붙이면 외부 링크가 적용된다.

하지만 extern의 적용 과정은 좀 복잡하다. 어떤 이름을 extern으로 지정하면 컴파일러는 이를 정의가 아닌 선언문으로 취급한다. 변수를 extern으로 지정하면 컴파일러는 그 변수에 대해 메모리를 할당하지 않는다. 따라서 그 변수를 정의하는 문장을 따로 작성해야 한다. 예컨대 AnotherFile.cpp에 나온 다음 문장이 그렇다.

extern int x;
int x = 3;

아니면 다음과 같이 extern으로 선언하는 동시에 초기화해도 된다. 그러면 선언과 정의를 한 문장에서 처리할 수 있다.

extern int x = 3;

이 경우는 굳이 extern이란 키워드를 적을 필요가 없다. extern을 붙이지 않아도 x는 기본적으로 외부 링크로 처리되기 때문이다. extern이 반드시 필요한 경우는 다음과 같이 FirstFile.cpp과 다른 소스 파일에서 x에 접근하게 만들 때다.

extern int x;

int main()
{
std::cout << x << std::endl;
}

FirstFile.cpp에서 x를 extern으로 선언했기 때문에 다른 파일에 있던 x를 여기서 사용할 수 있는 것이다. main()에서 x를 사용하려면 컴파일러가 x의 선언문을 알아야 한다. x를 선언하는 문장에 extern을 붙이지 않으면 이 문장이 x를 선언하는 것이 아니라 정의한다고 판단해서 메모리를 할당해 버린다. 그러면 링크 단계에서 에러가 발생한다. (전역 스코프에 x라는 변수가 두 개나 만들어지기 때문이다) 이럴 때는 extern을 붙여서 전역 변수로 만들면 여러 소스 파일에서 공유할 수 있다.

Caution) 전역 변수는 사용하지 않는 것이 좋다.

함수 안의 static 변수

마지막으로 소개할 static 키워드의 용도는 특정한 스코프 안에서만 값을 유지하는 로컬 변수를 만드는 것이다. 함수 안에서 static으로 지정한 변수는 그 함수만 접근할 수 있는 전역 변수와 같다. 주로 어떤 함수에서 초기화 작업 수행 여부를 기억하는 용도로 많이 사용한다.

void performTask()
{
static bool initialized = false;

if (!initialized)
{
cout << "initializing" << endl;
// 초기화 수행
initialized = true;
}

// 원하는 작업을 수행한다.
}

비로컬 변수의 초기화 순서

static 데이터 멤버와 전역 변수의 마지막 주제인 초기화 순서를 살펴보자. 전역 변수와 static 클래스 데이터 멤버는 모두 main()이 시작하기 전에 초기화된다. 이러한 변수는 소스 파일에 선언된 순서대로 초기화된다. 예컨대 다음과 같이 작성했다면 Demo::x는 y보다 먼저 초기화 된다.

class Demo
{
public:
static int x;
};

int Demo::x = 3;
int y = 4;

그런데 C++ 표준은 비로컬 변수가 여러 소스 파일에 선언 됐을 때 초기화 하는 순서는 따로 정해두지 않았다. 어떤 소스 파일에 x란 전역 변수가 있고, 다른 파일에는 y란 전역 변수가 있을 때 어느 것이 먼저 초기화되는지 알 수 없다. 어느 것이 먼저 초기화되든 상관 없는 경우가 대부분이긴 하지만 간혹 전역 변수나 static 변수가 서로 의존 관계에 있을 때는 문제가 발생할 수 있다.

(이하 설명 생략)

비로컬 변수의 소멸 순서

비로컬 변수는 생성된 순서와 반대로 소멸된다. 그런데 여러 소스 파일에 있는 비로컬 변수의 초기화 순서는 생성 순서와 마찬가지로 표준이 정해져 있지 않아서 정확한 소멸 순서를 알 수 없다.

타입과 캐스팅

타입 앨리어스

타입 앨리어스란 기존에 선언된 타입에 다른 이름을 붙이는 것이다. 타입을 새로 정의하지 않고 기존 타입 선언에 대한 동의어를 선언하는 문법이라 할 수 있다. 예컨대 다음 문장은 int*라는 타입 선언을 IntPtr란 새 이름으로 부르게 만든다.

using IntPtr = int*;

이렇게 하면 코드에서 방금 정의한 앨리어스나 기존 타입 선언 중에서 아무거나 써도 된다. 예컨대 다음과 같다.

int* p1;
IntPtr p2;

새로 정의한 타입으로 생성한 변수는 기존 타입 선언으로 생성한 변수와 완벽히 호환된다. 단순히 호환되는게 아니라 두 타입이 완전히 똑같기 때문이다.

타입 앨리어스는 너무 복잡하게 선언된 타입을 좀 더 간편하게 만들기 위한 용도로 많이 사용된다. 흔히 템플릿을 이용할 때 이런 경우가 많다. 예컨대 std::vector를 string 타입 원소에 대해 선언하려면 std::vector<std::string>과 같이 작성해야 하는데 타입 앨리어스를 사용해서 좀 더 짧고 의미가 드러나게 표현할 수 있다.

using StringVector = std::vector<std::string>

void processVector(const StringVector& vec)
{
// 코드 생략
}

타입 앨리어스를 정의할 때 스코프 지정자(scope qualifier)도 포함할 수 있다.

함수 포인터에 대한 타입 앨리어스

함수도 내부적으로 특정한 메모리 주소가 지정된다. 물론 함수의 메모리 위치를 신경 쓸 일은 많지 않다. 그런데 C++에서는 함수를 데이터로 취급할 수 있다. 다시 말해 함수의 주소를 변수 다루듯 사용할 수 있다.

함수 포인터의 타입은 매개변수 타입과 리턴 타입에 따라 결정된다. 함수 포인터를 다루는 방법 중 하나는 타입 앨리어스를 사용하는 것이다. 타입 앨리어스를 사용하면 특정한 속성을 가진 함수들을 타입 이름 하나로 부를 수 있다. 예컨대 int 매개변수 두 개와 bool 리턴값 하나를 가진 함수의 타입을 MatchFunction이란 이름으로 정의하려면 다음과 같이 작성한다.

using MatchFunction = bool(*)(int, int);

이렇게 타입 이름을 별도로 정의하면 MatchFunction을 매개변수로 받는 함수를 작성할 수 있다. 예컨대 다음 함수는 int 배열 두 개와 각각의 크기, MatchFunction을 인수로 받는다. 그리고 전달받은 두 배열에 대해 루프를 돌면서 각 원소를 MatchFunction의 인수로 전달하고 호출해서 true란 결과가 나오면 특정한 메시지를 화면에 출력한다. 여기서 주목할 부분은 MatchFuncton을 변수처럼 전달하더라도 여전히 일반 함수처럼 호출할 수 있다는 것이다.

void fintMatches(int values1[], int values2[], size_t numValues, MatchFunction matcher)
{
for (size_t i = 0; i < numValues; i++)
{
if (matcher(values1[i], values2[i])
{
cout << "Match found at position " << i << " (" << values1[i] << ", " << values2[i] << ")" << endl;
}
}
}

(이하 설명 생략. 함수 포인터는 C#에서 delegate랑 비슷하다. callback 메서드를 구성할 때 주로 사용)

메서드와 데이터 멤버를 가리키는 포인터에 대한 타입 앨리어스

C++는 클래스의 데이터 멤버와 메서드에 대한 주소를 가져오는 기능도 정식으로 지원한다. 하지만 non-static 데이터 멤버나 메서드는 반드시 객체를 통해 접근해야 한다. 클래스에서 데이터 멤버와 메서드를 저으이하는 목적은 객체마다 이들을 갖게 만들기 위해서다. 따라서 메서드나 데이터 멤버를 포인터로 접근하려면 반드시 해당 객체의 문맥에서 포인터를 역참조해야 한다.

Employee employee;
int (Employee::*methodPtr)() const = &Employee::getSalary;
cout << (employee.*methodPtr)() << endl;

두 번째 줄은 methodPtr란 변수를 선언하는데, 이 변수의 타입은 Employee에 있는 non-static const 메서드를 가리키는 포인터다. 이 메서드는 인수를 받지 않고 int 값을 리턴한다. 여기서는 선언과 동시에 변수의 값을 Employee 클래스의 getSalary() 메서드에 대한 포인터로 초기화했다. 이는 *methodPtr 앞에 Employee::가 붙은 점만 빼면 함수 포인터를 정의하는 문법과 비슷하다. 참고로 여기서는 &를 반드시 붙여야 한다.

세 번째 줄은 employee 객체를 통해 methodPtr 포인터로 getSalary() 메서드를 호출한다. 여기서 소괄호로 감싼 부분에 주목한다. 메서드 이름 뒤에 나온 ()는 *보다 우선순위가 높기 때문에 employee.*methodPtr를 소괄호로 묶어야 이 부분을 먼저 처리한다.

이 코드에서 타입 앨리어스를 활용하면 두 번째 줄을 다음과 같이 좀 더 읽기 쉽게 작성할 수 있다.

Employee employee;
using PtrToGet = int (Employee::*) () const;
PtrToGet methodPtr = &Employee::getSalary;
cout << (employee.*methodPtr)() << endl;

auto를 사용하면 훨씬 간결해진다.

Employee employee;
auto methodPtr = &Employee::getSalary;
cout << (employee.*methodPtr)() << endl;

프로그램을 작성할 때 메서드나 데이터 멤버에 대한 포인터를 사용할 일은 많지 않지만, non-static 메서드나 데이터 멤버에 대한 포인터는 객체를 거치지 않고서는 역참조 할 수 없다는 사실을 반드시 명심한다. 프로그래밍을 하다 보면 qsort()와 같이 함수 포인터를 받는 함수에 non-static 메서드의 포인터를 전달하는 실수를 저지르기 쉬운데, 이렇게 작성하면 작동하지 않는다.

Note) C++에서는 객체를 거치지 않고서도 static 데이터 멤버나 메서드의 포인터를 역참조 할 수 있다.

typedef

타입 앨리어스는 C++ 11부터 도입됐다. 그전에는 타입 앨리어스로 하는 일을 typedef로 구현해야 했는데 코드가 복잡했다. typedef도 타입 앨리어스와 마찬가지로 기존에 선언된 타입에 다른 이름을 붙여준다. 예컨대 다음과 같이 정의된 타입 앨리어스를 살펴보자.

using IntPtr = int*;

타입 앨리어스를 사용하기 전에는 다음과 같이 typedef로 비슷한 효과를 냈다.

typedef int* IntPtr;

가독성도 떨어지고 선언하는 순서도 반대라서 헷갈리기 쉽다. 코드가 좀 더 지저분해진다는 점을 제외하면 typedef는 기본적으로 타입 앨리어스와 같다. 예컨대 typedef 문을 선언한 뒤에는 다음과 같이 활용할 수 있다.

IntPtr p;

타입 앨리어스가 추가됙 ㅣ전에는 함수 포인터를 typedef로 표현해야 했다. 이 때문에 코드가 더욱 복잡해졌다. 예컨대 다음과 같이 정의된 타입 앨리어스를 살펴보자.

using FunctionType = int (*)(char, double);

이렇게 정의한 FunctionType을 typedef로 표현하면 다음과 같다.

typedef int (*FunctionType)(char, double);

타입 앨리어스와 typedef가 완전히 똑같은 것은 아니다. 템플릿에 활용할 때는 typedef보다 타입 앨리어스를 사용하는 것이 훨씬 유리하다.

Caution) typedef 보다는 타입 앨리어스를 사용하기 바란다.

캐스팅

const_cast()

const_cast()는 변수에 const 속성을 추가하거나 제거할 때 사용한다. 

(설명 생략)

C++ 17부터 std::as_const() 란 헬퍼 메서드가 추가되었다. 이 레퍼런스는 레퍼런스 매개변수를 const 레퍼런스 버전으로 변환해준다.

(설명 생략)

non-const를 const로 캐스팅할 때는 const_cast()보다 as_const()를 사용하는 것이 훨씬 간결하다. as_const()와 auto를 조합할 때 주의할 점이 있는데, auto는 레퍼런스와 const 속성을 제거한다는 것이다.

static_cast()

static_cast()는 언어에서 제공하는 명시적 변환 기능을 수행한다. 예컨대 정수에 대한 나눗셈이아닌 부동소수점에 대한 나눗셈으로 처리하도록 int를 double로 변환해야 할 때 static_cast()를 사용한다.

int i = 3;
int j = 4;
double result = static_cast<double>(i) / j;

사용자 정의 생성자나 변환 루틴에서 허용하는 명시적 변환을 수행할 때도 static_cast()를 사용할 수 있다. 상속 계층에서 하위 타입으로 다운 캐스팅할 때도 static_cast()를 사용할 수 있다.

(설명 생략)

static_cast()를 사용할 떄는 실행 시간에 타입 검사를 수행하지 않는다는 점에 주의한다. 실행 시간에 캐스팅할 때는 Base와 Derived가 실제 관련이 없어도 Base 포인터나 레퍼런스를 모두 Derived 포인터나 레퍼런스로 변환한다.

타입을 안전하게 캐스팅하도록 실행 시간에 타입 검사를 적용하려면 dynamic_cast()를 사용한다.

static_cast()를 그리 강력하지 않다. 포인터의 타입이 서로 관련 없을 때는 static_cast()를 적용할 수 없다. 또한 변환 생성자가 제공되지 않는 타입의 객체에도 적용할 수 없다. 기본적으로 C++ 타입 규칙에서 허용하지 않는 것은 모두 할 수 없다고 보면 된다.

reinterpret_cast()

reinterpret_cast()는 static_cast() 보다 강력하지만 안전성은 좀 떨어진다. C++ 타입 규칙에서 허용하지 않더라도 상황에 따라 캐스팅하는 것이 적합할 때 적용할 수 있다. 예컨대 서로 관련이 없는 레퍼런스끼리 변환할 수도 있다.

마찬가지로 상속 계층에서 아무런 관련이 없는 포인터 타입끼리도 변환할 수 있다. 이런 포인터는 흔히 void* 타입으로 캐스팅한다. 이 작업은 내부적으로 처리되기 때문에 명시적으로 캐스팅하지 않아도 되지만 이렇게 void*로 변환한 것을 다시 원래 타입으로 캐스팅할 때는 reinterpret_cast()를 사용해야 한다. void* 포인터는 메모리의 특정 지점을 가리키는 포인터일 뿐 void* 포인터 자체에는 아무런 타입 정보가 없기 때문이다.

class X {};
class Y {};

int main()
{
X x;
Y y;
X* xp = &x;
Y* yp = &y;

// 서로 관련 없는 클래스 타입의 포인터를 변환할 때는 reinterpret_cast()를 써야 한다.
xp = reinterpret_cast<X*>(yp);

// 포인터를 void*로 변환할 때는 캐스팅하지 않아도 된다.
void* p = xp;

// 변환된 void*를 다시 원래 포인터로 복원할 때는 reinterpret_cast()를 써야 한다.
xp = reinterpret_cast<X*>(p);

X& xr = x;
Y& yr = reinterpret_cast<Y&>(x);

return 0;
}

reinterpret_cast()를 활용하는 예로 ‘단순 복사 가능 타입’에 대해 바이너리 I/O를 수행하는 경우를 들 수 있다. 예컨대 이런 타입의 값을 파일에 바이트로 썼다가 나중에 다시 파일을 읽어서 메모리로 불러올 때 reinterpret_cast()를 적용하면 원래 값 그대로 정확히 해석할 수 있다.

하지만 reinterpret_cast()를 사용할 때는 타입 검사를 하지 않고 변환할 수 있기 때문에 주의해야 한다.

dynamic_cast()

dynamic_cast()는 같은 상속 계층에 속한 타입끼리 캐스팅할 때 실행 시간에 타입을 검샇나다. 포인터나 레퍼런스를 캐스팅할 때 이를 이용할 수 있다. dynamic_cast()는 내부 객체의 타입 정보를 실행 시간에 검사한다. 그래서 캐스팅하는 것이 적합하지 않다고 판단하면 포인터에 대해서는 널 포인터를 리턴하고 레퍼런스에 대해서는 std::bad_cast 익셉션을 발생 시킨다.

예컨대 다음과 같이 클래스 계층이 구성된 경우를 보자.

class Base
{
public:
virtual ~Base() = default;
};

class Derived : public Base
{
public:
virtual ~Derived() = default;
};

이때 dynamic_cast()에 대한 올바른 사용 예는 다음과 같다.

Base* b;
Derived* d = new Derived();
b = d;
d = dynamic_cast<Derived*>(b);

반면 레퍼런스에 대해 다음과 같이 dynamic_cast()를 적용하면 익셉션이 발생한다.

Base base;
Derived derived;
Base& br = base;

try
{
Derived& dr = dynamic_cast<Derived&>(br);
}
catch (const bad_cast&(
{
cout << "Bad cast!" << endl;
}

참고로 static_cast()나 reinterpret_cast()로도 같은 상속 계층의 하위 타입으로 캐스팅할 수 있다. 차이점은 dynamic_cast()는 실행 시간에 타입 검사를 수행하는 반면 static_cast()나 reinterpret_cast()는 문제가 되는 타입도 그냥 캐스팅한다는 것이다.

앞서 설명 했듯이 실행 시간의 타입 정보는 객체의 vtable에 저장된다. 따라서 dynamic_cast()를 적용하려면 클래스에 virtual 메서드가 최소한 한 개 이상 있어야 한다. 그렇지 않은 객체에 대해 dynamic_cast()를 적용하면 컴파일 에러가 발생한다.

캐스팅 정리

상황 캐스팅 방법
const 속성 제거 const_cast()
언어에서 허용하는 명시적 변환(int를 double로, int를 bool로) static_cast()
사용자 저으이 생성자나 변환 연산자에서 지원하는 명시적 변환 static_cast()
서로 관련 없는 타입의 객체끼리 변환 불가능
같은 상속 계층에 있는 클래스 타입의 객체 포인터 사이의 변환 dynamic_cast() 권장
static_cast()도 가능
같은 상속 계층에 있는 클래스 타입의 객체 레퍼런스 사이의 변환 dynamic_cast() 권장
static_cast()도 가능
서로 관련 없는 타입의 포인터 사이의 변환 reinterpret_cast()
서로 관련 없는 타입의 레퍼런스 사이의 변환 reinterpret_cast()
함수 포인터 사이의 변환 reinterpret_cast()

스코프

C++ 프로그래머라면 스코프(유효 범위)란 개념을 확실히 이해하고 있어야 한다. 변수, 함수, 클래스 이름을 비롯하여 프로그램에서 사용하는 모든 이름은 스코프에 속한다. 스코프는 네임스페이스, 함수 정의, 중괄호 블록, 클래스 정의 등으로 생성한다.

어떤 변수나 함수, 클래스를 주어진 이름으로 접근할 때 가장 가까운 스코프부터 시작하여 전역 스코프까지 검색한다. 그 이름을 전역 스코프에서도 찾지 못하면 컴파일러는 정의되지 않은 심벌이라는 에러(undefined symbol error)를 출력한다.

간혹 스코프 안에 다른 스코프와 같은 이름이 있으면 다른 스코프의 이름을 가린다. 또 어떤 경우는 현재 코드에서 사용하려는 대상의 스코프가 디폴트 스코프 범위에 속하지 않을 수도 있다. 이렇게 디폴트 스코프에 속하지 않은 이름을 사용할 때는 스코프 지정 연산자인 ::로 해당 스코프를 지정할 수 있다.

(이하 설명 생략)

어트리뷰트

어트리뷰트란 특정 벤더에서만 제공하는 정보나 옵션을 소스 코드에 추가하는 메커니즘이다. C++ 표준에 어트리뷰트가 추가되기 전에는 벤더마다 __attribute__, __declspec 과 같은 속성을 나름대로 정했다. C++ 11부터는 이런 속성을 모두 이중 대괄호를 이용하여 [[어트리뷰트]]와 같은 문법으로 표기하도록 표준화 했다.

C++ 표준은 6가지 어트리뷰트만 지원하는데, [[carries_dependency]] 어트리뷰트는 좀 특이해서 생략하고 나머지를 살펴보겠다.

[[noreturn]]

[[noreturn]]은 함수가 호출한 측으로 제어를 리턴하지 않는다는 것을 의미한다. 주로 프로세스나 스레드를 종료시키거나 익셉션을 던지는 함수에 이 어트리뷰트를 지정한다. 함수에 이 어트리뷰트를 붙이면 의도가 명확히 드러나기 때문에 컴파일러는 경고나 에러를 발생싴지 않는다. 예컨대 다음과 같다.

[[noreturn]] void forceProgramTermination()
{
std::exit(1);
}

bool isDongleAvailable()
{
bool isAvailable = false;
// 라이선싱 동글이 있는지 검사한다.
return isAvailable;
}

bool isFeatureLicensed(int featureId)
{
if (!isDongleAvailable())
{
// 라이선싱 동글을 찾지 못해서 프로그램 실행을 중단한다.
forceProgramTermination();
}
else
{
bool isLicensed = false;
// 동글을 발견하면 라이선스를 검사해서 주어진 기능을 제공하는지 확인한다.
return isLicensed;
}
}

int main()
{
bool isLicensed = isFeatureLicensed(42);
}

[[deprecated]]

[[deprecated]]는 더는 지원하지 않는 대상을 지정할 때 사용한다. 다시 말해 현재 사용할 수는 있지만 권장하지 않는 기능임을 표시한다. 이 어트리뷰트에 지원 중단 사유를 인수로 지정할 수 있다.

[[deprecated("Unsafe method, please use xyz")]] void func();

[[fallthrough]]

C++ 17부터 [[fallthrough]]란 어트리뷰트가 추가됐다. 이 어트리뷰트는 switch 문에서 의도적으로 폴스루(fallthrough, 완료되지 못한)를 적용하고 싶을 때 사용한다. 의도적으로 폴스루를 적용할 switch 문에 이 어트리뷰트를 지정하지 않으면 경고 메시지를 출력할 수 있다. 빈 케이스 문에는 지정하지 않아도 된다.

switch (backgroundColor)
{
case Color::DarkBlue:
doSomthingForDarkBlue();
[[fallthrough]];

case Color::Black:
// 배경색이 다크 블루나 블랙일 때 실행된다.
doSomthingForBlackOrDarkBlue();
break;

case Color::Red:
case Color::Green:
// 배경색이 레드나 그린일 때 실행된다.
break;
}

[[nodiscard]]

값을 리턴하도록 정의된 함수에 [[nodiscard]]를 지정하면 그 함수를 이용하는 코드에서 리턴 값을 사용하지 않을 때 경고 메시지가 발생한다.

[[nodiscard]] int func()
{
return 42;
}

int main()
{
func();
return 0;
}

이 기능은 에러 코드를 리턴하는 함수에 적용하면 좋다. 이런 함수에 [[nodiscard]]를 붙이면 에러 코드를 무시할 수 없게 만들 수 있다.

[[maybe_unused]]

[[maybe_unused]]는 프로그램에서 사용하지 않는 코드를 발견해도 경고 메시지를 출력하지 말라고 컴파일러에게 지시할 때 사용한다.

int func(int param1, [[maybe_unused]] int param2)
{
return 42;
}

벤더 정의 어트리뷰트

지금까지 살펴 본 어트리뷰트는 대부분 벤더에서 정의한 것이다. 벤더에서 이런 어트리뷰트를 정의할 때는 프로그램의 의미를 변경하는 용도가 아닌 컴파일러에서 코드를 최적화하거나 에러를 검사하는데 도움이 되기 위한 목적으로 제공해야 한다.

벤터마다 제공하는 어트리뷰트끼리 충돌할 수 있기 때문에 [[clang::noduplicate]]와 같이 벤더 이름을 붙이는 것이 좋다.

사용자 정의 리터럴

C++은 표준 리털을 다양하게 제공한다. 이런 리터럴은 프로그램에서 곧바로 사용할 수 있다.

  • ‘a’ : 문자
  • “character array”: 0으로 끝나는 문자 배열, 즉 C 스타일 스트링
  • 3.14f: 부동소수점 값
  • 0xabc: 16진수 숫자

C++는 리터럴을 직접 정의하는 기능도 제공한다. 이런 사용자 정의 리터럴은 반드시 언더스코어로 시작해야 한다. 그리고 언더스코어 다음에 나오는 첫 문자는 반드시 소문자여야 한다.

사용자 정의 리터럴은 리터럴 연산자를 정의하는 방식으로 구현한다. 리터럴 연산자는 미가공 모드(raw mode)나 가공 모드(cooked mode)로 작동한다. 미가공 모드에서는 단순히 문자가 나열된 것으로 취급하지만 가공 모드에서는 특정한 타입의 값으로 해석한다. 

예컨대 123이란 리터럴을 살펴보자. 미가공 모드의 리터럴 연산자는 이 값을 ‘1’, ‘2’, ‘3’이란 문자의 나열로 받지만 가공 모드의 리터럴 연산자는 123이란 정숫값으로 처리한다. 또 다른 예로 0x23이란 리터럴을 살펴보자. 미가공 모드에서는 ‘0’, ‘x’, ‘2’, ‘3’과 같이 문자를 나열한 것으로 처리하는 반면 가공 모드에서는 35라는 정수로 처리한다. 

가공 모드의 리터럴 연산자는 다음 두 조건 중 하나를 만족해야 한다.

  • 숫자값을 처리하려면 타입이 unsigned long long, long double, char, wchar_t, char16_t, char32_t 중 하나로 된 매개변수가 필요하다.
  • 스트링을 처리하려면 매개변수가 두 개 있어야 한다. 첫 번째는 문자 배열을 두 번째는 그 배열의 길이를 지정한다. (ex: const char* str, size_t len)

예컨대 복소수 리터럴을 정의하는 가공 모드 리터럴 연산자를 다음과 같이 _i라는 사용자 정의 리터럴로 구현할 수 있다.

std::complex<long double> operator"" _i(long double d)
{
return std::complex<long double>(0, d);
}

그러면 _i 리터럴을 다음과 같이 사용할 수 있다.

std::complex<long double> c1 = 9.634_i;
auto c2 = 1.23_i; // c2의 타입은 std::complex<long double>이 된다.

또 다른 예로 std::string 리터럴을 정의한느 사용자 정의 리터럴 _s에 대한 가공 모드의 리터럴 연산자를 다음과 같이 구현할 수 있다.

std::string operator"" _s(const char* str, size_t len)
{
return std::string(str, len);
}

그러면 다음과 같이 사용할 수 있다.

std::string str1 = "Hello World"_s;
auto str2 = "Hello World"_s; // str2의 타입은 std::string이 된다.

여기서 _s 리터럴을 사용하지 않으면 const char* 타입으로 자동 변환된다.

미가공 모드 리터럴 연산자를 정의하려면 0으로 끝나는 C 스타일 스트링을 const char* 타입으로 받는 매개변수를 한 개 정의해야 한다. 다음 코드는 _i라는 리터럴을 미가공 모드로 작동하도록 정의한다.

std::complex<long double> operator"" _i(const char* p)
{
// 구현 코드 생략
// C 스타일 스트링을 파싱해서 복소수로 변환하는 코드를 구현한다.
}

미가공 모드 리터럴 연산자를 사용하는 방법은 가공 모드 리터럴 연산자와 동일하다.

사용자 정의 리터럴에 대한 표준

C++은 다음과 같은 표준 사용자 정의 리터럴을 제공한다. 표준 사용자 정의 리터럴은 언더스코어로 시작하지 않는다는 점에 주의한다.

  • s: std::string 리터럴을 생성한다. 예컨대 다음과 같다.
    • auto myString = “Hello World”s;
  • sv: std::string_view 리터럴을 생성한다.
    • auto myStringView = “Hello World”sv;
  • h, min, s, ms, us, ns: 시간 간격을 표현하는 std::chrono::duration 리터럴을 생성한다.
    • auto myDuration = 42min;
  • i, il, if: 각각 complex<double>, complex<long double, complex<float> 타입의 복소수 리터럴을 생성한다.
    • auto myComplexNumber = 1.3i;

헤더 파일

헤더 파일이란 서브시스템이나 코드에 추상 인터페이스를 제공하는 메커니즘이다. 헤더를 사용할 때 헤더파일이 중복되거나 순환 참조가 발생하지 않도록 주의해야 한다.

인클루드 가드(include guard) 메커니즘을 사용하면 중복 정의를 피할 수 있다. 다음 코드는 Logger.h에서 인클루드 가드를 적용한 예를 보여주고 있다. 헤더 파일을 시작할 떄 #ifndef 지시자를 이용하여 특정한 키가 이미 정의 됐는지 검사한다. 그 키가 앞에서 정의된 적이 있다면 컴파일러는 #endif 문장까지 건너뛴다.

#ifdef LOGGER_H
#define LOGGER_H

class Logger
{
// ...
};

#endif // LOGGER_H

요즘 나온 컴파일러는 거의 모두 #pragma once 디렉티브를 제공하기 때문에 이렇게 인클루드 가드를 작성하지 않아도 된다.

#pragma once

class Logger
{
// ...
};

헤더 파일에 관련된 문제를 방지하기 위한 또 다른 기능으로 전방 선언(포워드 선언  forward declaration)이란 것도 있다. 어떤 클래스를 참조해야 하는데 그 클래스에 대한 헤더 파일을 인클루드 할 수 없다면 (예컨대 그 클래스가 현재 작성하는 클래스를 너무 많이 의존하고 있어서) 그 클래스에 대한 정의를 #include 메커니즘으로 불러오지 않고도 사용하게 만들 수 있다.

물론 이렇게 선언한 클래스에 대한 구현 사항은 컴파일러가 전혀 모르기 때문에 코드에서 실제로 사용할 수는 없다. 단, 이름이 있는 클래스라면 링크 단계가 모두 끝난 후에는 얼마든지 사용할 수 있다. 그래도 코드에서 전방 선언한 클래스에 대해 포인터나 레퍼런스를 만들어 쓸 수는 있다. 그리고 이렇게 전방 선언된 클래스를 값 전달 방식의 함수 매개변수나 리턴값의 타입으로 지정할 수도 있다. 물론 이런 함수를 정의하는 코드 뿐만 아니라 이를 호출하는 코드도 전방 선언된 클래스를 정의하는 헤더 파일을 정확히 인클루드 해야 한다.

예컨대 Logger 클래스에서 사용자 설정사항을 관리하도록 Preferences란 클래스를 사용한다고 하자. 그런데 Preferences 클래스는 다시 Logger 클래스를 이용해서 순환 의존 관계가 발생한다. 이런 상황은 인클루드 가드로는 해결할 수 없고, 전방 선언을 이용해야 한다. 다음 코드를 보면 헤더 파일을 인클루드하지 않고도 Preferences 클래스를 사용하도록 Logger.h 헤더 파일에 Preferences 클래스를 전방 선언한다.

#pragma once

#include <string_view>

class Preferences; // 전방 선언

class Logger
{
public:
static void setPreferences(const Preferences& prefs);
static void logError(std::string_view error);
};

헤더 파일에 다른 헤더 파일을 인클루드하는 대신 전방 선언을 적용하는 것이 바람직하다. 그러면 작성한 헤더 파일이 다른 헤더 파일을 의존하는 것을 제거해서 컴파일 시간이 크게 줄어들기 때문이다. 물론 전방 선언한 타입을 선언한 헤더 파일을 구현 파일에서 정확히 인클루드 해야 한다. 그렇지 않으면 컴파일 에러가 발생한다.

C++ 17부터 특정한 헤더 파일이 존재하는지 확인할 수 있도록 __has_include(“파일명”)과 __has_include(<파일명>) 이란 전처리 상수가 추가됐다. 이 상수는 해당 헤더 파일이 존재하면 1, 그렇지 않으면 0이 된다. 예컨대 C++ 17에서 <optional> 헤더 파일이 정식으로 지원되기 전에 <experimental/optional>이란 초기 버전의 헤더 파일이 제공됐다. 이때 __has_include()를 이용하면 현재 시스템에서 둘 중 어느 헤더 파일을 지원하는지 확인할 수 있다.

#if __has_include(<optional>)
#include <optional>
#elif __has_include(<experimental/optional>)
#include <experimental/optional>
#endif

C 유틸리티

가변 길이 인수 리스트

기존 C 언어의 기능인 가변 길이 인수 리스트(variable-length argument list)를 살펴보자 간혹 레거시 코드에서 사용하기 때문에 알아둘 필요가 있다. 물론 새로 작성하는 코드에서는 타입에 안전한 가변 길이 인수 리스트를 제공하는 가변 인수 템플릿(variadic template)을 사용하는 것이 바람직하다.

C/C++는 가변 길이 인수를 가진 함수를 직접 정의하는데 필요한 문법과 유틸리티 매크로를 제공한다. 이렇게 정의한 함수는 printf()와 형태가 비슷하다. 물론 이 기능이 필요할 때가 많지 않지만 간혹 쓰면 편할 때가 있다. 간단한 구현 예는 다음과 같다.

#include <cstudio>
#include <cstdarg>

bool debug = false;

void debugOut(const char* str, ...)
{
va_list ap;

if (debug)
{
va_start(ap, str);
vfprintf(stderr, str, ap);
va_end(ap);
}
}

코드에서 가장 먼저 주목할 부분은 debugOut()에 대한 프로토타입이다. 이 부분을 보면 이름과 타입이 지정된 str이라는 매개변수 뒤에 생략 부호(…)가 있다. 이는 임의 개수와 타입의 인수를 받을 수 있다는 것을 의미한다. 이렇게 선언된 인수는 반드시 <cstdarg>에 정의된 매크로로 접근해야 한다.

함수 본문을 보면 va_list 타입의 변수를 선언한 뒤 va_start를 호출해서 초기화했다. va_start()의 두 번째 매개변수는 반드시 매개변수 리스트의 이름 있는 변수 중에서 오른쪽 끝에 있는 것이어야 한다.

가변 길이 인수 리스트를 가진 함수라면 반드시 이름 있는 매개변수가 한 개 이상 있어야 한다. 여기서 정의한 debugOut() 함수는 인수 리스트를 곧바로 <cstudio>에 있는 표준 함수인 vfprintf()로 전달한다. vfprintf()를 호출한 결과가 리턴되면 va_end()를 호출해서 가변 길이 인수 리스트에 대한 접근을 종료한다. va_start()를 호출했다면 반드시 이에 대응되는 va_end()를 호출해서 함수의 스택 상태를 일관성 있게 유지해야 한다.

이렇게 정의한 debugOut() 함수를 사용하는 방법은 다음과 같다.

debug = true;
debugOut("int %d\n", 5);
debugOut("String %s and int %d\n", "hello", 5);
debugOut("Many ints: %d, %d, %d, %d, %d\n", 1, 2, 3, 4, 5);

인수에 접근하기

실제 인수에 직접 접근하고 싶다면 va_arg()를 사용한다. 이 매크로는 va_list와 이를 해석할 타입을 인수로 받는다. 하지만 아쉽게도 인수 리스트의 끝은 명시적으로 지정하지 않고서는 알아낼 방법이 없다. 예컨대 첫 번째 매개변수를 매개변수 개수로 지정할 수 있다. 또는 포인터가 여러 개 있을 때 마지막 포인터를 nullptr로 지정하는 방법도 있다. 이 외에도 다양한 방법이 있지만 모두 프로그래머 입장에서 번거롭다.

다음 코드는 함수를 호출한 측에서 이름 있는 매개변수 중 첫 번째 매개변수로 인수 개수를 지정할 수 있게 구현하는 예를 보여주고 있다. 이 함수는 임의 개수의 int 값을 받아서 출력한다.

void printInts(size_t num, ...)
{
int temp;
va_list ap;

va_start(ap, num);

for (size_t i = 0; i < num; ++i)
{
temp = va_arg(ap, int);
cout << temp << " ";
}

va_end(ap);

cout << endl;
}

이렇게 정의한 printInts()를 호출하는 방법은 다음과 같다. 이 문자을 보면 첫 번째 매개변수는 뒤에 나올 정수 인수 개수를 지정한다.

printInts(5, 5, 4, 3, 2, 1);

C 스타일 가변 길이 인수 리스트를 사용하면 안 되는 이유

C 스타일 가변 길이 인수 리스트는 그리 안전하지 않다. printInts() 함수 예제에서 알 수 있듯이 다음과 같은 문제가 있다.

  • 매개변수 개수를 알 수 없다. printInts()의 예를 보면 호출한 측이 첫 번째 인수에 정확한 개수를 입력하길 믿는 수 밖에 없다. debugOut()의 경우 문자 배열 뒤에 나올 인수 개수가 스트링에 담긴 포매팅 코드 개수와 일치한다고 믿는 수 밖에 없다.
  • 인수 타입을 알 수 없다. va_arg() 매크로는 전달된 타입을 이용하여 현재 시점의 값을 해석한다. 그런데 va_arg()를 통해 얼마든지 그 값이 다른 타입으로 해석될 수도 있다. 따라서 타입이 정확한지 검증할 방법이 없다.

Caution) C 스타일 가변 길이 인수 리스트는 가능하면 사용하지 않는 것이 좋다. 그 대신 값을 std::array나 vector에 담아서 전달하고, 1장에서 설명한 것처럼 그 값을 이니셜라이저 리스트로 초기화하거나, 22장에서 설명하는 타입에 안전한 가변 길이 인수 리스트에 대한 variadic 템플릿을 사용한다.

전처리 매크로

C++ 전처리기로 간단한 함수 형태의 매크로를 정의할 수 있다. 예컨대 다음과 같다.

#define SQUARE(x) ((x) * (x)) // 매크로 뒤에는 세미콜론을 붙이지 않는다.

int main()
{
cout << SQUARE(5) << endl;
return 0;
}

매크로는 C 언어에서 물려 받은 기능으로 타입 검사를 하지 않고 이를 호출한 모든 부분을 전처리기가 텍스트 바꾸기를 하듯 교체한다는 점을 제외하면 inline 함수와 비슷하다. 이때 전처리기는 실제 함수 호출 메커니즘을 적용하지 않는다. 그래서 의도하지 않은 결과가 발생할 수 있다.

예컨대 SQUARE에 2+3이란 인수를 지정하는 경우를 생각해 보자.

cout << SQUARE(2+3) << endl;

위 결과는 25가 아닌 11이 되는데, 매크로는 함수 호출 메커니즘이 아닌 단순 텍스트 바꾸기이기 때문이다. 떄문에 앞서 매크로를 사용한 문장은 실제로 다음과 같다.

cout << (2+3 * 2+3) << endl;

매크로를 잘못 사용하면 성능에 지장을 줄 수 있다. 예컨대 SQUARE를 다음과 같이 호출한 경우를 보자.

cout << SQUARE(veryExpensiveFunctionCallToComputeNumber()) << endl;

전처리기는 이 문장을 다음과 같이 바꾼다.

cout << (veryExpensiveFunctionCallToComputeNumber() & veryExpensiveFunctionCallToComputeNumber()) << endl;

그러면 굉장히 오래 걸리는 함수를 두 번이나 호출하게 된다. 매크로를 사용하면 안 되는 또 다른 이유다.

매크로는 디버깅할 때도 문제가 된다. 전처리기는 찾아서 바꾸는 방식으로 처리하기 때문에 프로그래머가 볼 때와 컴파일러나 디버거가 볼 때 코드 형태가 서로 다르다. 그래서 매크로 대신 인라인 함수를 사용하는 것이 바람직하다.

이러한 세부사항을 소개하는 이유는 상당수의 C++ 코드에서 여전히 매크로를 사용하기 때문이다. 따라서 기존에 작성된 코드를 읽고 관리하려면 개념을 정확히 이해할 필요가 있다.

Note) 컴파일러는 대부분 전처리기를 거친 소스 코드를 다른 파일이나 표준 출력으로 출력하는 기능을 제공한다. 이 겨로가를 이용해 전처리기의 코드 처리 방식을 파악할 수 있다.

전문가를 위한 C++/ 상속 활용하기

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

상속을 이용한 클래스 구현

클래스 확장하기

C++에서 클래스를 정의할 때 컴파일러에게 기존 클래스를 상속(inherit), 파생(derive), 확장(extend) 한다고 선언할 수 있다. 이렇게 하면 새로 만들 클래스에 기존 클래스의 데이터 멤버와 메서드를 자동으로 가져올 수 있다. 이때 원본 클래스를 부모 클래스(베이스 클래스 또는 슈퍼 클래스(super class))라 부른다. 그러면 기존 클래스를 확장한 자식 클래스(child class) 또는 파생(derived class) 또는 서브 클래스(subclass)는 부모 클래스와 다른 부분만 구현하면 된다.

Base 클래스가 다음과 같이 생겼다고 하자.

class Base
{
public:
void someMethod();
protected:
int mProtectedInt;
private:
int mPrivateInt;
}

이제 Derived 클래스가 Base 클래스를 상속하도록 만들어 보자.

class Derived : public Base
{
public:
void someOtherMethod();
}

(이하 설명 생략)

클라이언트 입장에서 본 상속

다른 코드에서 볼 때 Derived 타입의 객체는 Base 타입의 객체이기도 하다. Derived는 Base를 상속했기 때문이다. 따라서 Base에 있는 public 메서드나 데이터 멤버 뿐만 아니라 Derived의 public 메서드와 데이터 멤버도 사용할 수 있다.

상속은 반드시 한 방향으로만 진행된다는 점에 주의한다. Derived 클래스 입장에서는 Base 클래스와의 관계가 상당히 명확하지만 Base 클래스를 정의하는 시점에서는 Derived 클래스의 존재를 알 수 없다.

어떤 객체를 포인터나 레퍼런스로 가리킬 때 그 객체를 선언한 클래스의 객체뿐만 아니라 그 클래스의 파생 클래스 객체도 가리킬 수 있다. 레퍼런스도 마찬가지다. 클라이언트는 기본적으로 Base에 있는 데이터 멤버나 메서드에 접근할 수 있지만 상속을 통해 Base에 적용되는 코드를 Derived에도 적용할 수 있다. 

예컨대 다음과 같이 작성하면 타입이 맞지 않는 것처럼 보이지만 정상적으로 컴파일 된다.

Base* base = new Derived();  // Derived 객체를 생성해서 Base 포인터에 저장한다.

그런데 Base 포인터로 Derived 클래스의 메서드를 호출할 수는 없다. 다음과 같이 작성하면 컴파일 에러가 발생한다.

base->someOtherMethod();

객체의 실제 타입이 Derived이고 실제로 someOtherMethod()가 정의돼 있지만 컴파일러는 여전히 이 객체의 타입이 someOtherMethod()가 없는 Base로 알고 있기 때문이다.

파생 클래스 입장에서 본 상속

파생 클래스를 작성하는 방법은 일반 클래스와 기본적으로 같다. 파생 클래스의 메서드와 데이터 멤버도 일반 클래스처럼 정의한다.

(이하 설명 생략)

상속 방지

C++에서 클래스를 정의할 때 final 키워드를 붙이면 다른 클래스가 이 클래스를 상속할 수 없다. final로 선언한 클래스를 상속하면 컴파일 에러가 발생한다. 

class Base final
{
// 코드 생략
}

메서드 오버라이딩

클래스를 상속하는 주된 이유는 기능을 추가하거나 바꾸기 위해서다. 베이스 클래스에 정의된 메서드의 동작은 변경할 일이 많은데, 이는 메서드 오버라이딩을 이용해 가능하다.

메서드 오버라이딩과 virtual 속성

C++에서는 베이스 클래스에 virtual 키워드로 선언된 메서드만 파생 클래스에서 오버라이드 할 수 있다. 예컨대 Base 클래스의 메서드를 virtual로 선언하려면 다음과 같이 작성한다.

class Base
{
public:
virtual void someMethod();
protected:
int mProtectedInt;
private:
int mPrivateInt;
}

일반적으로 모든 메서드를 virtual로 선언하는 것이 바람직한데, 그러면 오버라이드하는 과정에서 메서드가 제대로 작동할지 신경 쓸 필요가 없다. 하지만 이렇게 하면 성능이 좀 떨어진다.

메서드 오버라이딩 문법

파생 클래스에서 베이스 클래스의 메서드를 오버라이드하려면 그 메서드를 베이스 클래스에 나온 것과 똑같이 선언하고 맨 뒤에 override 키워드를 붙인다.

class Derived : public Base
{
public:
virtual void someMethod() override; // Base의 someMethod 오버라이딩
virtual void someOtherMethod();
}

메서드나 소멸자를 virtual로 지정하면 모든 파생 클래스에서도 virtual 상태를 유지한다. 파생 클래스에서 virtual 키워드를 제거해도 마찬가지다. 예컨대 Derived 클래스에서 someMethod()를 다음과 같이 선언해도 Base 클래스에서 virtual로 선언했기 때문에 여전히 Derived를 상속한 클래스에서 이를 오버라이드 할 수 있다.

class Derived : public Base
{
public:
virtual void someMethod() override; // Base의 someMethod 오버라이딩
}

클라이언트 관점에서 본 오버라이드한 메서드

(설명 생략)

앞서 설명한 것처럼 포인터나 레퍼런스는 해당 클래스뿐만 아니라 파생 클래스 객체까지 가리킬 수 있다. 객체 자신은 멤버가 어느 클래스에 속해 있는지 알기 때문에 virtual로 선언됐다면 가장 적합한 메서드를 호출한다.

예컨대 다음과 같이 Derived 객체를 가리키는 레퍼런스를 Base 타입으로 선언한 상태에서 someMethod()를 호출하면 파생 클래스 버전이 호출된다. 하지만 베이스 클래스에 virtual이란 키워드가 없었다면 오버라이드한 버전이 호출되지 않는다.

Derived myDerived;
Base& ref = myDerived;
ref.someMethod(); // Derived 버전의 someMethod()가 호출된다.

이렇게 파생 클래스를 인식해서 적합한 메서드를 호출하는 기능은 포인터나 레퍼런스 객체에만 적용된다. Derived 타입은 일종의 Base 타입이기 때문에 Derived를 Base로 캐스팅하거나 Derived 객체를 Base 변수에 대입할 수는 있다. 하지만 이렇게 하면 캐스팅하거나 대입하는 순간 파생 클래스 정보가 사라진다.

Derived myDerived;
Base assignedObject = myDerived; // Base 변수에 Derived 객체를 대입한다.
assignedObject.someMethod(); // Base 버전의 someMethod()를 호출한다.

내부적인 처리 과정이 헷갈린다며 ㄴ객체가 메모리에 저장된 상태를 떠올리면 쉽다. Base 객체가 메모리의 일정 영역을 차지하고 있고, Derived 객체는 그보다 더 큰 메모리 영역에 Base 객체의 내용에 몇 가지 사항이 더 추가됐다고 생각할 수 있다. 이 상태에서 포인터나 레퍼런스로 Derived 객체를 가리키면 메모리 영역은 그대로이고 접근 방식만 달라진다. 하지만 Derived를 Base로 캐스팅하면 메모리 영역이 축소돼서 Derived 클래스에서 추가한 정보가 사라진다.

Note) 파생 클래스에서 오버라이드한 메서드를 베이스 클래스 포인터나 레퍼런스로 참조할 때는 원래 상태를 유지한다. 파생 클래스 객체를 베이스 클래스 타입으로 캐스팅할 때만 파생 클래스 고유의 정보가 사라진다. 이렇게 파생 클래스의 데이터 멤버다 오버라이드된 메서드가 삭제되는 것을 슬라이싱(slicing)이라 한다.

override 키워드

(생략)

virtual 메서드

virtual로 선언하지 않음 메서드를 오버라이드하면 몇 가지 미묘한 문제가 발생한다. 따라서 오버라이드할 메서드는 항상 virtual로 선언하는 것이 좋다.

오버라이드하지 않고 숨기기

클래스 정의가 다음과 같다고 하자.

class Base
{
public:
void go() { cout << "go() called on Base" << endl; }
}

class Derived : public Base
{
public:
void go() { cout << "go() called on Derived" << endl; }
}

이 상태에서 Derived 객체의 go() 메서드를 호출해 보자.

Derived myDerived;
myDerived.go();

그러면 기대했던 대로 ‘go() called on Derived’가 출력된다. 하지만 이 메서드는 virtual로 선언되지 않았기 때문에 실제로 오버라이드 된 것이 아니라 Derived 클래스에 go() 란 이름을 갖는 메서드가 새로 생성된 것이다. 이 메서드는 Base 클래스의 go()와 전혀 다르다. 다음과 같이 Base 포인터 또는 레퍼런스로 이 메서드를 호출해 보면 정말 다른지 확인할 수 있다.

Derived myDerived;
Base& ref = myDerived;
ref.go();

이번에는 ‘go called on Base’가 출력된다. ref 변수는 Base 타입 레퍼런스인데 Base 클래스 안에서 virtual 키워드를 지정하지 않았기 때문에 이 메서드가 파생 클래스에도 있는지 찾아보지 않는다. 그래서 go() 메서드를 호출하면 Base의 go() 메서드가 호출되는 것이다.

Caution) virtual로 선언하지 않음 메서드를 오버라이드하면 베이스 클래스 정의를 숨겨버린다. 그래서 오버라이드한 메서드를 파생 클래스 문맥에서만 사용할 수 있게 된다.

virtual 메서드의 내부 작동 방식

C++에서 클래스를 컴파일하면 그 클래스의 모든 메서드를 담은 바이너리 객체가 생성된다. 그런데 컴파일러는 virtual로 선언되지 않은 메서드를 호출하는 부분을 컴파일 시간에 결정된 타입의 코드로 교체한다. 이를 정적 바인딩(static biding) 또는 이른 바인딩(early binding)이라 부른다.

메서드를 virtual로 선언하면 vtable(가상 테이블)이라 부르는 특수한 메모리 영역을 활용해서 가장 적합한 구현 코드를 호출한다. virtual 메서드가 하나 이상 정의된 클래스마다 vtable이 하나씩 있는데, 이 클래스로 생성한 객체마다 이 vtable에 대한 포인터를 갖게 된다.

virtual 메서드의 구현 코드에 대한 포인터는 바로 이 vtable에 담겨 있다. 그래서 객체에 대해 메서드를 호출하면 vtable을 보고 그 시점에 적합한 버전의 메서드를 실행한다. 이를 동적 바인딩(dynamic binding) 또는 늦은 바인딩(late binding)이라 부른다.

다음과 같이 정의된 Base와 Derived 클래스를 통해 vtable로 메서드 오버라이드를 처리하는 과정을 좀 더 구체적으로 살펴보자.

class Base
{
public:
virtual void func1() {}
virtual void func2() {}
void nonVirtualFunc() {}
}

class Derived : public Base
{
public:
virtual void func2() override {}
void nonVirtualFunc() {}
}

이 상태에서 인스턴스 두 개를 생성한다.

Base myBase;
Derived myDerived;

아래 그림은 이렇게 생성한 두 인스턴스가 vtable에 표현된 모습을 보여준다. myBase 객체는 vtable에 대한 포인터를 갖고 있으며, 이 vtable에는 func1()와 func2()에 대한 항목이 있다. 각 항목은 Base::func1()과 Base::func2()의 구현 코드를 가리킨다.

myDerived도 마찬가지로 vtable에 대한 포인터를 가지며, func1()과 func2()에 대한 항목으로 구성된다. 그런데 Derived가 func1()을 오버라이드하지 않기 때문에 func1()에 대한 항목은 Base::func1()을 가리킨다. 반면 func2()에 대한 항목은 Derived::func2()를 가리킨다.

여기서 주목할 점은 vtable 모두 nonVirtualFunc() 메서드에 대한 항목을 가지지 않는다는 점이다. 이 메서드는 virtual로 선언되지 않았기 때문이다.

virtual 키워드가 필요한 이유

앞서 메서드를 모두 virtual 로 선언하는 것이 좋다고 했는데, 굳이 virtual 키워드가 따로 존재하는 이유가 궁금할 것이다. 어차피 모두 virtual 로 선언할 것이라면 컴파일러가 알아서 virtual로 선언하면 되기 때문이다. 실제로 자바에서는 그렇게 하고 있다.

하지만 모두 virtual로 처리하면 안 된다고 주장하는 사람도 있는데, 애초에 vtable에 대한 오버헤드를 줄이기 위해 virtual 키워드를 만들었기 때문이다. virtual 메서드를 호출하려면 가장 적합한 코드를 선택하는 과정에서 포인터를 역참조 해야 한다.

(이하 설명 생략)

virtual 소멸자의 필요성

메서드를 모두 virtual로 선언하는 방식에 반대하는 프로그래머도 소멸자만큼은 virtual로 선언해야 한다고 생각한다. 소멸자를 virtual로 선언하지 않으면 객체가 소멸할 때 메모리가 해제되지 않을 수 있기 때문이다. 클래스를 final로 선언할 때를 제외한 나머지 경우는 항상 소멸자를 virtual로 선언하는 것이 좋다.

예컨대 파생 클래스의 생성자에서 동적으로 할당된 메모리를 사용하다가 소멸자에서 삭제하도록 작성했을 때 소멸자가 호출되지 않으면 메모리가 해제되지 않는다. 마찬가지로 std::unique_ptr처럼 파생 클래스에서 자동으로 삭제되는 멤버가 있을 때 그 클래스의 인스턴스가 삭제될 때 소멸자를 호출하지 않으면 이런 멤버가 삭제되지 않고 남게 된다.

다음 코드를 보면 virtual로 선언되지 않은 소멸자가 호출되지 않는 일이 얼마나 발생하기 쉬운지 알 수 있다.

class Base
{
public:
Base() {}
~Base() {}
}

class Derived : public Base
{
public:
Derived()
{
mString = new char[30];
cout << "mString allocated" << endl;
}

~Derived()
{
delete[] mString;
cout << "mString deallocated" << endl;
}

private:
char* mString;
};

int main()
{
Base* ptr = new Derived(); // mString이 여기서 할당된다.
delete ptr; // ~Base는 호출되지만 ~Derived는 호출되지 않는다. 소멸자를 virtual로 선언하지 않았기 때문.

return 0;
}

이 코드를 실행하면 Derived 객체의 소멸자가 호출되지 않은 것을 확인할 수 있다. 실제로 앞서 나온 코드에서 deelte를 호출하는 동작은 표준에 정의돼 있지 않고 C++ 컴파일러마다 나름대로 구현하고 있다. 그런데 대부분의 컴파일러는 파생 클래스의 소멸자가 아닌 베이스 클래스의 소멸자를 호출하도록 처리한다.

Note) 소멸자에서 처리할 일은 없고 virtual로만 지정하고 싶다면 다음과 같이 디폴트로 지정한다.

class Base
{
public:
virtual ~Basae() = default;
}

참고로 C++ 11장부터 클래스에 사용자 선언 소멸자가 있을 때 복제 생성자와 복제 대입 연산자를 생성해주는 기능이 폐기됐다. 그래도 여전히 복제 생성자와 복제 대입 연산자를 컴파일러에서 생성해주는 기능이 필요하다면 명시적으로 디폴트로 지정한다.

Caution) 특별한 이유가 없거나 클래스를 final로 선언하지 않았다면 소멸자를 포함한 모든 메서드를 virtual로 선언한다. 단, 생성자는 virtual로 선언할 수 없고 그럴 필요도 없다. 객체를 생성할 때 항상 정확한 클래스를 지정하기 때문이다.

오버라이딩 방지하기

메서드도 final로 지정할 수 있다. 메서드를 final로 지정하면 파생 클래스에서 오버라이드할 수 없다.

class Base
{
public:
virtual ~Base() = default;
virtual void someMethod() final;
}

(이하 생략)

코드 재사용을 위한 상속

(상속 예시 생략)

부모를 공경하라

부모 클래스의 생성자

C++는 객체 생성 과정을 다음과 같이 정의하고 있다.

  1. 베이스 클래스라면 디폴트 생성자를 실행한다. 단 생성자 이니셜라이저가 있다면 디폴트 생성자 대신 생성자 이니셜라이저를 호출한다.
  2. static으로 선언하지 않은 데이터 멤버를 코드에 나타난 순서대로 생성한다.
  3. 클래스 생성자의 본문을 실행한다.

이 규칙은 재귀적으로 적용된다. 클래스에 부모 클래스가 있다면 현재 클래스보다 부모 클래스를 먼저 초기화하는데, 만일 그 부모의 부모 클래스가 있다면 그 클래스를 먼저 초기화한다.

아래 예시 코드를 실행하면 123이라는 결과가 나온다.

class Something
{
public:
Something() { cout << "2"; }
}

class Base
{
public:
Base() { cout << "1"; }
}

class Derived : public Base
{
public:
Derived() { cout << "3"; }
private:
Something mDataMember;
}

int main()
{
Derived myDerived;
return 0;
}

myDrived 객체가 생성되면 Base 생성자가 먼저 호출되면서 문자열 “1”을 출력한다. 다음으로 mDataMember가 초기화되면서 Something 생성자를 호출한다. 그러면 문자열 “2”를 출력한다. 마지막으로 Derived 생성자가 호출되면서 문자열 “3”을 출력한다.

여기서 Base 생성자는 자동으로 호출된다. C++는 부모 클래스에 디폴트 생성자가 있으면 자동으로 호출해준다. 부모 클래스에 디폴트 생성자가 없거나 있더라도 다른 생성자를 사용할 때는 생성자 이니셜라이저로 데이터 멤버를 초기화할 때와 같은 방식으로 생성자를 체인으로 엮을 수 있다.

예컨대 다음 코드는 디폴트 생성자 없이 Baser 클래스를 정의했다. 이를 상속한 Derived 는 반드시 컴파일러에 Base 생성자를 호출한느 방법을 알려줘야 한다. 그렇지 않으면 컴파일 에러가 발생한다.

class Base
{
public:
Base(int i);
}

class Derived : public Base
{
public:
Derived();
}

Derived::Derived() : Base(7)
{
// Derived의 다른 초기화를 여기서 실행한다.
}

여기 나온 Derived 생성자는 고정된 값(7)을 Base 생성자로 전달한다. Derived 클래스에서도 이처럼 생성자에서 인수를 받는다면 Base처럼 변수를 전달할 수 있다.

Derived::Derived(int i) : Base(i) {}

파생 클래스에서 베이스 클래스로 생성자 인수를 전달해도 아무런 문제 없다. 지극히 정상적인 처리 방식이다. 하지만 데이터 멤버를 전달할 수는 없다. 그렇게 해도 컴파일 에러가 발생하지 않지만 데이터 멤버는 베이스 클래스 생성자가 실행된 후에야 초기화 된다. 따라서 부모 생성자에 데이터 멤버를 인수로 전달하면 초기화되지 않는다.

Caution) 생성자 안에서는 virtual 메서드의 동작 방식이 다르다. 파생 클래스에서 베이스 클래스의 virtual 메서드를 오버라이드하고 그 메서드를 베이스 클래스 생성자에서 호출하면 파생 클래스에서 오버라이드한 버전이 아닌 베이스 클래스에 구현된 virtual 메서드가 호출된다.

부모 클래스의 소멸자

소멸자는 인수를 받지 않기 때문에 부모 클래스의 소멸자는 언제나 자동으로 호출되게 할 수 있다. 소멸자의 호출 과정은 다음과 같이 생성자의 반대다.

  1. 현재 클래스의 소멸자를 호출한다.
  2. 현재 클래스의 데이터 멤버를 생성할 때와 반대 순서로 삭제한다.
  3. 부모 클래스가 있다면 부모의 소멸자를 호출한다.

이 규칙도 생성자와 마찬가지로 재귀적으로 적용된다. 상속 체인의 가장 하위 멤버를 먼저 삭제한다. 다음 코드를 실행하면 123321이 출력된다.

class Something
{
public:
Something() { cout << "2"; }
virtual ~Somthing() { cout << "2"; }
}

class Base
{
public:
Base() { cout << "1"; }
virtual ~Base() { cout << "1"; }
}

class Derived : public Base
{
public:
Derived() { cout << "3"; }
virtual ~Derived() { cout << "3"; }
private:
Something mDataMember;
}

여기서 소멸자를 virtual로 선언하지 않아도 코드 실행에는 문제 없다. 하지만 파생 클래스를 가리키는 베이스 클래스 타입 포인터에 대해 delete를 호출하면 소멸자 실행 순서가 뒤바뀐다. 예컨대 앞에 나온 코드에서 소멸자 앞에 있는 virtual 키워드를 모두 삭제하고 다음과 같이 Derived 객체를 가리키는 Base 타입 포인터를 삭제하면 문제가 발생한다.

Base* ptr = new Derived();
delete ptr;

이 코드를 실행하면 놀랍게도 “1231”이란 결과가 나온다. ptr 변수를 삭제하면 Base 소멸자만 호출되는데, 이는 소멸자를 virtual 로 지정하지 않았기 때문이다. 결국 Derived 소멸자가 호출되지 않아 Derived의 데이터 멤버에 대한 소멸자도 호출되지 않았다.

Base 생성자 앞에 virtual이란 키워드만 추가해도 문제를 해결할 수 있다. virtual 속성은 자식 클래스에도 자동으로 적용되기 때문이다. 하지만 애초에 이런 문제를 걱정하지 않도록 항상 모든 소멸자를 virtual로 선언하는 것이 낫다.

부모 클래스 참조하기

(생략)

자식이 부모의 메서드를 호출하려면 스코드 지정 연산자를 이용한다.

class WeatherPrediction
{
public:
virtual std::string getTemperature() const;
}

class MyWeatherPrediction : public WetherPrediction
{
public:
virtual std::string getTemperature() const override;
}

string MyWeatherPrediction::getTemperature() const
{
// 부모 메서드를 호출한 뒤 단위 추가
return WetherPrediction::getTemperature() + "\u00B0F";
}

Note) MS 비주얼 C++에서 제공하는 __super(언더스코어가 2개임) 키워드를 사용해서도 작성할 수 있다. 

return __super::getTemparature() + "\u00B0F";

(이하 생략)

업캐스팅과 다운캐스팅

앞서 본 것처럼 객체를 부모 클래스 타입으로 캐스팅하거나 대입할 수 있다. 기존 객체를 캐스팅이나 대입하면 자식 클래스의 특성이 사라지는 슬라이싱이 발생한다.

Base myBase = myDerived; // 슬라이싱이 발생한다.

하지만 파생 클래스 타입의 객체를 베이스 클래스 타입의 포인터나 레퍼런스에 대입할 때는 슬라이싱이 발생하지 않는다.

Base& myBase = myDerived; // 슬라이싱이 발생하지 않는다.

베이스 클래스 타입으로 파생 클래스를 참조하는 것을 업캐스팅(upcasting)이라 한다. 바로 이 때문에 객체가 아닌 객체의 레퍼런스를 함수나 메서드로 전달하도록 구성하는 것이 좋다. 이렇게 레퍼런스를 활용하면 슬라이싱 없이 파생 클래스를 전달할 수 있다.

반면 베이스 클래스를 파생 클래스로 캐스팅하는 것을 다운캐스팅(downcasting)이라 하는데, 이렇게 하면 해당 객체가 반드시 파생 클래스에 속한다고 보장할 수 없고, 다운 캐스팅이 있다는 것은 디자인이 잘못된 것을 의미하기 때문에 C++ 프로그래머는 다운캐스팅을 부정적으로 본다.

간혹 다운캐스팅이 필효한 경우가 있다면 dynamic_cast()를 사용할 수 있다. 이 함수는 객체 내부에 저장된 타입 정보를 보고 캐스팅이 잘못됐다면 처리하지 않는다. 이런 타입 정보는 vtable에 담겨 있기 때문에 dynamic_cast()는 vtable이 있는, 다시 말해 virtual 멤버가 하나라도 있는 객체에만 적용할 수 있다. 

포인터 변수에 대해 dynamic_cast()가 실패하면 포인터의 값이 임의의 객체가 아닌 nullptr이 된다. 또 객체 레퍼런스에 대해 dynamic_cast()가 실패하면 std::bad_cast 익셉션이 발생한다.

void lessPresumptuous(Base* base)
{
Derived* myDerived = dynamic_cast<Derived*>(base);

if (myDerived != nullptr)
{
// myDerived로 Derived의 메서드에 접근하는 코드를 작성한다.
}
}

(이하 설명 생략)

다형성을 위한 상속

스프레드시트 예제

(생략)

다형성을 지원하는 스프레드시트 셀 디자인하기

(생략)

SpreadsheetCell 베이스 클래스

첫 번째 버전

(생략)

순수 가상 메서드와 추상 베이스 클래스

순수 가상 메서드(pure virtual method)란 클래스 정의 코드를 명시적으로 정의하지 않는 메서드다. 메서드를 순수 가상 메서드로 만들면 컴파일러는 이 메서드에 대한 정의가 현재 클래스에 없다고 판단한다.

순수 가상 메서드가 최소한 하나라도 정의된 클래스를 추상 클래스(abstract class)라 한다. 추상 클래스에 대해서는 인스턴스를 만들 수 없다. 클래스에 순수 가상 메서드가 한 개 이상 있으면 컴파일러는 이 클래스가 객체를 생성하는데 사용되지 않는다고 판단한다.

순수 가상 메서드를 지정하려면 메서드 선언 뒤에 =0을 붙인다. 그리고 구현 코드는 작성하지 않ㅎ는다.

class SpreadsheetCell
{
public:
virtual ~SpreadsheetCell() = default;
virtual void set(std::string_view inString) = 0;
virtual std::string getString() const = 0;
}

이렇게 하면 베이스 클래스인 SpreadsheetCell은 추상 클래스가 되기 때문에 SpreadsheetCell 객체를 생성할 수 없다. 

(C++은 명시적으로 인터페이스 뿐만 아니라 abstract keyword도 지원하지 않는다. 그래서 불편한 방식으로 인터페이스와 추상 클래스를 구현함)

파생 클래스 구현하기

베이스 클래스에서 순수 가상으로 정의한 메서드 중 파생 클래스에서 구현하지 않은 것이 하나라도 있으면 파생 클래스도 추상클래스가 되기 때문에 부모 클래스로부터 받은 순수 가상 메서드를 하나도 빠짐 없이 구현해야 한다.

(생략)

다형성 최대로 활용하기

(생략)

나중에 대비하기

객체지향 디자인 관점에서 볼 때 SpreadsheetCell을 계층화해서 구현하는 것이 좋다. 그래도 (예시 코드) 여전히 실전에서 사용하기 부족한 기능들이 있는데 소개하면 다음과 같다.

첫째, 셀 타입끼리 상호 변환하는 기능이 빠졌다. 타입을 변환하는 기능을 별도로 제공해야하는데 DoubleSpreadsheetCell을 StringSpreadsheetCell 타입으로 변환하는 기능은 변환 생성자(converting constructor, 또는 타입 생성(typed constructor)를 추가하는 방식으로 구현할 수 있다.

이는 복제 생성자와 비슷하지만 동일한 클래스가 아니라 형제 클래스 객체에 대한 레퍼런스를 인수로 받는다. 이렇게 하면 디폴트 생성자를 반드시 선언해야 한다. 명시적으로 디폴트로 지정해도 된다. 생성자를 직접 작성하면 컴파일러가 더는 자동으로 만들어주지 않기 때문이다.

class StringSpreadsheetCell : public SpreadsheetCell
{
public:
StringSpreadsheetCell() = default;
StringSpreadsheetCell(const DoubleSpreadsheetCell& inDoubleCell);
// 코드 생략
}

이렇게 선언한 변환 생성자의 구현 코드는 다음과 같다.

StringSpreadsheetCell::StringSpreadsheetCell(const DoubleSpreadsheetCell& inDoubleCell)
{
mValue = inDoubleCell.getString();
}

변환 생성자를 이용하면 DoubleSpreadsheetCell을 이용해서 StringSpreadsheetCell 객체를 생성하기 쉬워진다. 하지만 포인터나 레퍼런스를 캐스팅할 때와는 방식이 다르다는 점에 주의한다. cast 연산자를 오버로드하지 않으면 포인터나 레퍼런스 타입을 형제 클래스끼리 캐스팅할 수 없다.

둘째, 셀마다 연산자를 오버로드해야 한다. 구현 방식은 여러 가지가 있다. 모든 셀 타입 조합에 대해 연산자를 일일이 구현하는 것이다.

(설명 생략)

다중 상속

여러 클래스 상속하기

다중 상속을 정의하는 방법은 간단하다. 클래스 이름 옆에 상속할 베이스 클래스를 나열하기만 하면 된다.

class Baz : public Foo, public Bar
{
// 코드 생략
}

(C#이나 자바 등은 죽음의 다이아몬드 문제 때문에 다중 상속을 허용하지 않는다. 대신 인터페이스를 다중으로 사용하는 것은 허용됨. 아마도 C++은 인터페이스가 명시적으로 없기 때문에 다중 상속을 지원하는게 아닐까 싶다. 여튼 나는 부정적인 입장이기도 하고 개념적으로 특별한게 없으므로 이하 내용 생략)

이름 충돌과 모호한 베이스 클래스

(생략)

상속에 관련된 미묘하면서 흥미로운 문제들

오버라이드한 메서드의 속성 변경하기

대부분 메서드의 구현을 변경하기 위해 오버라이드한다. 하지만 간혹 메서드를 오버라이드하는 과정에서 원래 메서드의 속성도 함꼐 변경하고 싶을 때가 많다.

리턴 타입 변경하기

오버라이드할 메서드는 베이스 클래스에 선언된 내용, 즉 베이스 클래스의 메서드 프로토타입과 똑같이 작성하는 것이 원칙이다. 구현 코드는 달라질 수 있어도 프로토타입은 똑같아야 한다.

하지만 이렇게 하지 않을 수도 있다. C++은 베이스 클래스의 리턴 타입이 다른 클래스에 대한 포인터나 레퍼런스 타입이면 메서드를 오버라이드할 때 리턴 타입을 그 클래스의 파생 클래스에 대한 포인터나 레퍼런스 타입으로 바꿀 수 있다. 이런 타입을 공변 리턴 타입(covariant return type)이라 부른다. 베이스 클래스와 파생 클래스가 병렬 계층(parallel hierarchy)를 이룰 때, 다시 말해 두 계층이 따로 전재하지만 어느 한 쪽이 관련이 있을 때 이 기능이 유용할 수 있다.

예컨대 체리 과수원 시뮬레이터가 있다고 하자. 여기서 클래스 계층을 두 개로 구성하는데, 첫 번째 계층은 Cherry 베이스 클래스와 BingCherry 파생 클래스를 구성하고 두 번째 계층은 CherryTree 베이스 클래스와 BingCherryTree 파생 클래스로 구성한다.

이제 CherryTree 클래스에 체리 하나를 따는 pick()이라는 가상 메서드를 정의한다.

Cherry* CherryTree::pick()
{
return new Cherry();
}

이제 파생 클래스인 BingCherryTree에서 이 메서드를 오버라이드해보자. 예컨대 BingCherry를 딸 때 깨끗이 닦는 동작을 추가할 수 있다. BingCherry도 일종의 Cherry이기 때문에 메서드 프로토타입은 그대로 유지하고 메서드의 구현 코드만 다음과 같이 수정한다. BingCherry 포인터는 Cherry 포인터로 자동으로 캐스팅된다. 

Cherry* :BingCherryTree:pick()
{
auto theCherry = std::make_unique<BingCherry>();
theCherry->polish();
return theCherry.release();
}

이렇게 구현해도 문제없이 실행된다. 그런데 이 코드는 BingCherry 객체를 리턴한다. 그래서 다음과 같이 리턴 타입을 변경하면 이 사실을 사용자에게 알려줄 수 있다.

BingCherry* BingCherryTree::pick()
{
auto theCherry = std::make_unique<BingCherry>();
theCherry->polish();
return theCherry.release();
}

이렇게 구현하면 BingCherryTree::pick() 메서드를 다음과 같이 호출할 수 있다.

BingCherryTree theTree;
std::unique_ptr<Cherry> theCherry(theTree.pick());
theCherry->printType();

오버라이드하는 과정에서 원본 메서드의 리턴 타입을 변경해도 되는지 알아내기 위한 좋은 방법은 이렇게 바꿔도 기존 코드가 제대로 동작하는지 확인하는 것이다. 이를 리스코프 치환 원칙(Liskov subsitiution principle, LSP)라 부른다. 앞서 나온 코드에서 리턴 타입을 변경해도 문제가 발생하지 않은 이유는 모든 코드에서 pick() 메서드가 항상 Cherry*를 리턴한다고 가정한 코드는 모두 문제 없이 컴파일되고 실행되기 때문이다. BingCherry도 일종의 Cherry이기 때문에 CherryTree 버전의 pick()이 리턴한 값에 대해 호출할 수 있는 메서드는 모두 BingCherryTree 버전의 pick()이 리턴한 값에 대해서도 호출할 수 있다.

하지만 리턴 타입을 void*와 같이 전혀 관련 없는 타입으로는 변경할 수 없다. 또한 위 코드는 스마트 포인터가 아닌 일반 포인터를 사용했는데, 따라서 리턴 타입을 std::unique_ptr로 지정하면 작동하지 않는다.

std::unique_ptr<Cherry> CherryTree:pick()
{
return std::make_unique<Cherry>();
}

그러면 BingCherryTree::pick() 메서드의 리턴 타입을 unique_ptr<BingCherry>로 변경할 수 없다. 다시 말해 아래와 같이 작성하면 컴파일 에러가 발생한다.

class BingCherryTree : public CherryTree
{
public:
virtual std::unique_ptr<BingCherry> pick() override;
}

그 이유는 std::unique_ptr가 클래스 템플릿이기 때문이다. 

메서드 매개변수 변경하기

파생 클래스를 정의하는 코드에서 virtual 메서드를 선언할 때 이름은 부모 클래스에 있는 것과 똑같이 쓰고 매개변수만 다르게 지정하면 부모 클래스의 메서드가 오버라이드 되는 것이 아니라 새로운 메서드가 정의된다.

class Base
{
public:
virtual void someMethod();
};

class Derived : public Base
{
public:
virtual void someMethod(int i); // 컴파일 에러는 없지만 오버라이드되지 않는다.
virtual void someOtherMethod();
};

이렇게 정의하면 에러는 없지만 someMethod()가 오버라이드 되지 않는다. 인수가 다르기 때문에 Derived에만 존재하는 새 메서드가 생성된다. 

C++ 표준에 따르면 Derived 클래스에서 메서드를 이렇게 정의하면 부모 클래스에 있던 메서드를 가린다. 따라서 다음과 같이 작성하면 인수를 받지 않는 someMethod()가 없기 때문에 컴파일 에러가 발생한다.

Derived myDerived;
myDerived.someMethod(); // 원본 메서드가 가려졌기 때문에 컴파일 에러가 발생한다.

Base에 있는 someMethod()를 오버라이드하려 했다면 메서드를 이렇게 작성할 때 override 키워드를 붙여야 한다. 그래야 이 메서드를 오버라이드하는 코드에 잘못된 부분이 있으면 컴파일러가 에러 메시지를 출력한다.

그런데 파생 클래스에서 프로토타입이 달라져도 실질적으로 클래스 버전을 상속하는 효과를 낼 수 있는 특이한 테크닉이 있다. 방법은 다음과 같이 파생 클래스에서 베이스 클래스 메서드를 정의하고 그 앞에 using 키워드를 지정하면 된다.

class Base
{
public:
virtual void someMethod();
};

class Derived : public Base
{
public:
using Base::someMethod; // Base 버전을 명시적으로 상속한다.
virtual void someMethod(int i); // 새 버전을 추가한다.
virtual void someOtherMethod();
};

생성자 상속

앞서 using 키워드로 베이스 클래스에 저으이된 메서드를 파생 클래스에 명시적으로 지정하는 방법을 소개했다. 그런데 이 기법은 일반 클래스 메서드 뿐만 아니라 생성자에도 적용할 수 있다. 그러면 베이스 클래스의 생성자도 상속할 수 있다.

class Base
{
public:
virtual ~Base() = default;
Base() = default;
Base(std::string_view str);
};

class Derived : public Base
{
public:
Derived(int i);
};

이렇게 정의된 Base의 디폴트 생성자나 string_view 매개변수를 받는 생성자로 Base 객체를 만들 수 있다. 반면 Derived 객체는 여기서 선언한 int 타입 인수를 받는 생성자로만 만들 수 있다. Base 클래스에 정의된 string_view 인수를 받는 생성자로는 Derived 객체를 만들 수 없다.

string_view 인수를 받는 Base 생성자로 Derived 객체를 만들고 싶다면 다음과 같이 Derived 클래스에서 Base 생성자를 명시적으로 상속해야 한다.

class Derived : public Base
{
public:
using Base::Base;
Derived(int i);
}

using 키워드를 지정하면 부모 클래스의 디폴트 생성자를 제외한 모든 생성자를 상속한다. 따라서 위와 같이 작성하면 Derived 객체를 다음 두 가지 방식으로 생성할 수 있다.

Derived derived1(1);  // int 인수를 받는 Derived 생성자 호출
Derived derived2("Hello");  // string_view 인수를 받는 Base 생성자 호출

여기서 Derived 클래스는 Base에서 상속한 생성자 중에서 매개변수 리스트가 똑같은 생성자를 정의할 수 있다. 오버라이드할 때와 마찬가지로 Derived 클래스 생성자의 우선순위가 Base의 생성자보다 높다.

다음 예제는 Derived 클래스에서 using 키워드를 이용해서 디폴트 생성자를 제외한 Base 클래스의 모든 생성자를 상속한다. 그런데 Derived 클래스에 float 타입의 매개변수 하나만 받는 생성자를 정의했다. 그러므로 Base 클래스에 있는 float 타입 버전의 생성자는 오버라이드 된다.

class Base
{
public:
virtual ~Base() = default;
Base() = default;
Base(std::string_view str);
Base(float f);
};

class Derived : public Base
{
public:
using Base::Base;
Derived(float f); // float 버전의 Base 생성자를 오버라이드 한다.
};

이렇게 정의하면 Derived 객체를 다음과 같이 두 가지 방식으로 생성할 수 있다.

Derived derived1("Hello");  // Base의 string_view 버전 생성자가 호출된다.
Derived derived2(1.23f);  // Derived의 float 버전 생성자가 호출된다.

using 구문으로 베이스 클래스의 생성자를 상속할 때 몇 가지 제약사항이 있다. 베이스 클래스의 생성자를 상속하면 디폴트 생성자를 제외한 모든 생성자를 한 번에 상속한다. 베이스 클래스 생성자 중 일부만 상속할 수는 없다. 또한 다중 상속과 관련된 제약사항도 있다. 여러 베이스 클래스에서 매개변수 목록이 똑같은 생성자는 상속할 수 없다. 어느 부모에 있는 것을 호출할지 알 수 없기 때문이다.

(이하 다중 상속 관련 예시 생략)

메서드 오버라이딩의 특수한 경우

베이스 클래스가 static인 경우

C++에서는 static 메서드를 오버라이드 할 수 없다. 이 정도만 알아도 충분하지만 다음과 같은 사항도 알아두면 좋다.

먼저 static과 virtual을 동시에 지정할 수 없다. static 메서드를 오버라이드하면 원래 의도와 다른 효과가 발생한다. 파생 클래스에 있는 static 메서드의 이름이 베이스 클래스의 static 메서드와 같으면 서로 다른 메서드 두 개가 생성된다.

(이하 설명 생략)

베이스 클래스 메서드가 오버로드된 경우

베이스 클래스에 다양ㅎ나 버전으로 오버로드된 메서드가 여러 개 있는데 그 중 한 버전만 오버라이드 하면 컴파일러는 베이스 클래스에 있는 다른 버전의 메서드도 함께 가려버린다. 이렇게 하는 이유는 컴파일러 입장에서 볼 때 어느 한 버전만 오버라이드 했다는 것은 원래 같은 이름을 가진 모든 메서드를 오버라이드하려다가 깜빡 잊고 하나만 적었다고 판단하기 때문이다. 그래서 이대로 놔두면 에러가 발생할 수 있으므로 나머지 메서드를 모두 가려주는 것이다.

다음 코드를 보다. 여기서 Derived 클래스는 Base 클래스의 오버로드된 여러 메서드 중에서 하나만 오버라이드했다.

class Base
{
public:
virtual ~Base() = default;
virtual void overload() { cout << "Base's overload()" << endl; }
virtual void overload(int i)
{
cout << "Base's overload(int i)" << endl; }
}
};

class Derived : public Base
{
public:
virtual void overload() override
{
cout << "Derived's overload()" << endl; }
}
}

이렇게 정의된 상태에서 Derived 객체로 int 버전의 overload() 메서드를 호출하면 컴파일 에러가 발생한다. 그런데 Derived 객체에서 이 버전의 메서드에 접근할 방법은 있다. Derived 객체를 가리킬 변수를 Base 포인터나 Base 레퍼런스로 만들면 된다.

Derived myDerived;
Base& ref = myDerived;
ref.overload(7);

이렇게 파생 클래스에서 오버라이드 하지 않은 부모 클래스의 오버로드된 메서드를 숨겨주는 것은 사실 C++에서 편의상 제공하는 기능일 뿐이다. 객체의 타입을 명시적으로 파생 클래스로 지정해버리면 이런 메서드를 가리긴 하지만 언제든지 베이스 클래스로 캐스팅하면 가려진 메서드에 얼마든지 접근할 수 있다.

실제로 수정하고 싶은 버전은 하나뿐인데 그것만 오버라이드하면 부모 클래스에 있는 나머지 오버로드된 메서드가 몽땅 가려지는 문제 때문에 파생 클래스에서 모든 버전을 오버로드하는 것은 번거롭다. 이럴 때는 using 키워드를 사용해서 간편하게 처리할 수 있다.

class Base
{
public:
virtual ~Base() = default;
virtual void overload() { cout << "Base's overload()" << endl; }
virtual void overload(int i)
{
cout << "Base's overload(int i)" << endl; }
}
};

class Derived : public Base
{
public:
using Base::overload;
virtual void overload() override
{
cout << "Derived's overload()" << endl; }
}
}

이렇게 using 키워드를 사용할 때 한 가지 주의할 점이 있다. Derived를 이렇게 정의하고 나서 나중에 Base에 overload() 메서드의 또 다른 오버로딩 버전을 추가했는데, 공교롭게도 이 버전도 Derived에서 오버라이드 해야 하는 것이라고 하자. 그런데 앞의 코드처럼 using 구문이 나와 있으면 Base에 있는 오버로드된 버전 중에서 Derived 클래스에 명시적으로 오버라이드 하지 않은 나머지 버전은 있는 그대로 받아서 쓰는 것으로 처리돼버린다. 그래서 이렇게 오버라이드를 빼먹은 메서드는 에러 형태로 드러나지 않아 나중에 문제가 발생해도 찾기 힘들 수 있다.

private나 protected로 선언된 베이스 클래스 메서드

private이나 protected 메서드도 얼마든지 오버라이드할 수 있다. 메서드에 대한 접근 지정자는 그 메서드를 호출할 수 있는 대상만 제한할 뿐이다. 파생 클래스에서 부모 클래스의 private 메서드를 호출할 수 없다고 해서 오버라이드도 할 수 없는 것은 아니다.

실제로 private이나 protected 메서드를 오버라이드 하는 사례를 C++ 코드에서 굉장히 자주 볼 수 있다. 반면 자바와 C#은 public, protected 메서드는 오버라이드 할 수 있고, private 메서드는 오버라이드 할 수 없다.

(설명 생략)

베이스 클래스 메서드에 디폴트 인수가 지정된 경우

파생 클래스와 베이스 클래스에서 지정한 디폴트 인수가 서로 다를 수 있다. 그런데 실행할 때 적용할 인수는 실제 내부에 있는 객체가 아닌 변수에 선언된 타입에 따라 결정된다. 다음과 같이 파생 클래스에서 메서드를 오버라이드할 때 디폴트 인수를 다르게 지정한 경우를 보자.

class Base
{
public:
virtual ~Base() = default;
virtual void go(int i = 2)
{
cout << "Base's go with i=" << i << endl;
}
};

class Derived : public Base
{
public:
virtual void go(int i = 7) override
{
cout << "Derived's go with i=" << i << endl;
}
};

Derived 객체를 통해 go()를 호출하면 Derived에 정의된 버전의 go() 메서드가 실행되고 디폴트 인수는 7이 적용된다. 또한 Base 객체를 통해 go()를 호출하면 Base 버전의 go()가 호출되고 디폴트 인수는 2가 적용된다. 

그런데 특이하게도 실제로는 Derived 객체를 가리키지만 Base 타입이나 Base 레퍼런스로 선언된 변수로 go()를 호출하면 Derived 버전의 go() 코드가 실행되지만 디폴트 인수는 Base에 지정된 2가 적용된다.

이렇게 실행되는 이유는 C++가 디폴트 인수를 실행 시간의 타입이 아닌 컴파일 시간의 타입을 보고 결정하기 때문이다. C++에서 디폴트 인수는 상속되지 않는다. 이 예제처럼 Derived 클래스가 부모와 다르게 디폴트 인수를 지정하면 인수를 갖는 버전의 새로운 go() 메서드가 오버로드 된다.

Note) 디폴트 인수가 지정된 메서드를 오버라이드할 때는 파생 클래스에서도 반드시 디폴트 인수를 지정하고 기왕이면 값도 똑같이 지정하는 것이 좋다. 이때 디폴트값을 기호 상수(symbolic constant)로 표현하는 것이 좋다. 그러면 파생 클래스에서도 똑같은 기호 상수로 표현할 수 있다.

베이스 클래스 메서드와 접근 범위를 다르게 지정하는 경우

메서드를 오버라이드 할 때 접근 권한을 넓히거나 좁힐 수 있다. 흔치 않지만 C++ 프로그래밍시 이렇게 해야 할 때가 있다.

메서드나 데이터 멤버에 대한 접근 권한을 좀 더 제한하는 방법은 두 가지가 있다. 하나는 베이스 클래스 전체에 대한 접근 지정자를 변경하는 것이다. 이에 대해서는 뒤에서 설명하겠다. 또 달느 방법은 아래 코드와 같이 Shy 클래스처럼 파생 클래스에서 접근자를 다르게 지정하는 것이다.

class Gregarious
{
public:
virtual void talk()
{
cout << "Gregarious say hi!" << endl;
}
};

class Shy: public Gregarious
{
protected:
virtual void talk() override
{
cout << "Shy reluctantly says hello" << endl;
}
};

Shy 클래스에서 정의한 protected 버전의 talk() 메서드는 Gregarious::talk() 메서드를 아무런 문제 없이 오버라이드 한다. 그래서 외부에서 Shy 객체의 talk() 메서드를 호출하면 컴파일 에러가 발생한다.

그런데 이 메서드의 접근 범위는 protected로 완벽하게 변경되지 않았는데 Gregarious 타입의 레퍼런스나 포인터를 이용하면 얼마든지 이 메서드에 접근할 수 있다.

이처럼 파생 클래스에서 메서드를 오버라이드 하는데는 문제 없지만 베이스 클래스에서 public으로 선언한 메서드를 protected로 접근 범위를 제한하는 것은 완벽하게 적용되지 않는다.

파생 클래스에서 접근 범위를 좁히는 것보다 넓히는 것이 더 쉽다. 사실 그게 더 자연스럽다.

class Secret
{
protected:
virtual void donTell() { cout << "I'll never tell" << endl; }
};

class Blabber : public Secret
{
public:
virtual void tell() { dontTell(); }
};

물론 위와 같이 한다고 해서 dontTell() 메서드의 접근 범위가 변경되는 것은 아니다. 다만 이 메서드에 접근하는 경로만 제공하는 것이다.

또 다른 방법은 메서드를 오버라이드하면서 접근 범위를 public으로 변경하는 방법이 있다. 접근 범위를 축소하는 것보다 훨씬 현실적이다. 베이스 클래스에 대한 레퍼런스나 포인터를 사용할 때 어떻게 처리될지 명확히 드러나기 때문이다.

class Blabber : public Secret
{
public:
virtual void dontTell() override { cout << "I'll tell all!" << endl; }
};

오버라이드한 메서드의 구현 코드는 그대로 두고 접근 범위만 변경하고 싶다면 다음과 같이 using 문을 작성한다.

class Blabber : public Secret
{
public:
using Secret::dontTell;
};

이 두 경우 모두 베이스 클래스 메서드의 접근 범위는 여전히 protected이므로 Secret 타입의 포인터나 레퍼런스로 dontTell() 메서드를 외부에서 호출하면 컴파일 에러가 발생한다.

파생 클래스의 복제 생성자와 대입 연산자

파생 클래스에 포인터 같은 특수한 데이터(주로 포인터)가 있어서 디폴트가 아닌 복제 생성자나 대입 연산자를 저읭해야 할 경우가 아니라면 베이스 클래스에 복제 생성자나 대입 연산자가 있더라도 파생 클래스에서 다시 정의할 필요가 없다.

파생 클래스에서 복제 생성자나 대입 연산자를 정의하는 코드를 생략하면 파생 클래스의 데이터 멤버에 대한 디폴트 생성자나 대입 연산자가 생성되고, 베이스 클래스의 데이터 멤버에 대해서는 베이스 클래스의 복제 생성자나 대입 연산자가 적용된다.

반면 파생 클래스에서 복제 생성자를 명시적으로 정의하면 다음코드처럼 반드시 부모 클래스의 복제 생성자를 호출해야 한다. 그렇지 않으면 객체에서 부모 부분에 대해 디폴트 생성자가 사용된다.

class Base
{
public:
virtual ~Base() = default;
Base() = default;
Base(const Base& src);
};

Base::Base(const Base& src)
{
}

class Derived : public Base
{
public:
Derived() = default;
Derived(const Derived& src);
}

Derived::Derived(const Derived& src) : Base(src)
{
}

마찬가지로 파생 클래스에서 operator=을 오버라이드하면 객체의 일부분만 대입 연산을 적용할 때처럼 극히 드문 경우를 제외하면 부모 버전의 대입 연산자도 함께 호출해야 한다.

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

Base::operator=(rhs); // 부모의 대입 연산자를 호출한다.
// 대입 연산 수행 코드 생략
return *this;
}

실행 시간 타입 정보

C++는 다른 객체지향 언어보다 컴파일 시간에 결정하는 것이 많다. 앞서 설명했듯이 메서드 오버라이드는 객체 내부에 있는 클래스 정보가 아닌 메서드 선언과 구현의 연결 관계를 보고 작동한다.

물론 C++도 실행 시간에 객체를 들여다보는 기능을 제공한다. 이를 한데 묶어 RTTI(Run-Time Type Information, 실행 시간 타입 정보)라 부른다. RTTI는 객체가 속한 클래스 정보를 다루는데 필요한 기능을 다양하게 제공한다. 대표적인 예로 dynamic_cast()가 있다. 

RTTI에서 제공하는 또 다른 기능으로 typeid 연산자가 있다. 이 연산자를 이용하면 실행 시간에 객체의 타입 정보를 조회할 수 있다. typeid를 사용할 일은 거의 없다. 객체의 타입에 따라 다르게 실행되는 코드는 virtual 메서드로 구현하는 것이 바람직하기 때문이다.

#include <typeinfo>

class Animal { public: virtual ~Animal() = default; };
class Dog : public Animal {};
class Bird : public Animal {};

void speak(const Animal& animal)
{
if (typeid(animal) == typeid(Dog))
{
cout << "Woof!" << endl;
}
else if (typeid(animal) == typeid(Bird))
{
cout << "Chirp!" << endl;
}
}

typeid 연산자는 주로 로깅 및 디버깅 용도로 사용한다. 다음 코드는 typeid로 로깅을 구현한 예를 보여준다. logObject() 함수는 Loggable 객체를 매개변수로 받는다. 로깅을 지원할 객체는 모두 Loggable을 상속해서 getLogMessage() 메서드를 제공하도록 디자인했다.

class Loggable
{
public:
virtual ~Loggable() = default;
virtual std::string getLogMessage() const = 0;
};

class Foo : public Loggable
{
public:
std::string getLogMessage() const override;
};

std::string Foo::getLogMessage() const
{
return "Hello logger";
}

void logObject(const Loggable& loggableObject)
{
cout << typeid(loggableObject).name() << ": ";
cout << loggableObject.getLogMessage() << endl;
}

logObject() 함수는 객체의 클래스 이름부터 출력하고 그 뒤에 로그 메시지를 작성한다. 이렇게 하면 나중에 로그 파일을 읽을 때 각 메시지가 속한 객체를 알기 쉽다.

Note) 로깅이나 디버깅 용도가 아니라면 typeid보다 virtual 메서드로 구현하는 것이 좋다.

non-public 클래스 상속

부모를 private이나 protected로 지정할 수는 없을까? 가능하긴 하지만 public 만큼 흔하지 않다. 부모에 대한 접근 범위를 일괄적으로 축소하는데는 다양한 이유가 있겠지만 대부분 상속 관계를 잘못 디자인했기 때문이다.

(이하 설명 생략)

가상 베이스 클래스

다중 상속을 설명하면서 동일한 클래스를 상속하는 부모 클래스를 여러 개 상속하면 모호함이 발생한다고 설명했다. 그런데 C++는 이렇게 중복되는 부모 클래스도 자체 기능을 가질 수 있도록 가상 베이스 클래스라는 기능을 제공한다. 중복되는 부모가 가상 베이스 클래스라면 모호한 상황이 발생할 일이 없다.

다음 코드는 Animal 베이스 클래스에 sleep() 메서드를 추가하고, Dog와 Bird 클래스에서 Animal을 가상 베이스 클래스로 상속하도록 수정했다. 이 과정에서 만약 Animal 클래스 앞에 virtual 키워드를 지정하지 않으면 DogBird 객체로 sleep()을 호출할 때 모호함이 발생해서 컴파일 에러가 발생한다. DogBird 입장에서 볼 때 Animal의 하위 타입이 두 개 (Dog와 Bird) 나 있기 때문이다.

하지만 Animal을 가상으로 상속하면 Animal의 하위 타입이 하나만 생성되기 때문에 sleep()을 호출할 때 모호한 상황이 발생하지 않는다.

class Animal
{
public:
virtual void eat() = 0;
virtual void sleep() { cout << "zzzzz..." << endl; }
};

class Dog : public virtual Animal
{
public:
virtual void bark() { cout << "Woof!" << endl; }
virtual void eat() override { cout << "The dog ate" << endl; }
};

class Bird : public virtual Animal
{
public:
virtual void chirp() { cout << "Chirp!" << endl; }
virtual void eat() override { cout << "The bird ate" << endl; }
};

class DogBird : public Dog, public Bird
{
public:
virtual void eat() override { Dog::eat(); }
}

int main()
{
DogBird myConfusedAnimal;
myConfusedAnimal.Sleep(); // 베이스를 virtual로 선언해서 모호함이 발생하지 않음
return 0;
}

Note) 클래스 계층의 모호함을 방지하기 위한 좋은 방법은 가상 베이스 클래스를 사용하는 것이다. 유일한 단점은 이 개념을 모르는 C++ 프로그래머가 많다는 것이다.

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

(전체가 아니라 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를 호출해도 문제가 발생하지 않는다. 그저 아무 일도 하지 않을 뿐이다.

전문가를 위한 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장 생략)