int
배열을 캡슐화해 보유한 IntArray
클래스를 소개한다. 배열 원소에 인덱스로 접근하려면 표준 []
배열 인덱스 연산자를 사용하면 된다. 게다가 배열의 경계를 벗어났는지도 점검할 수 있다. 흥미롭게도 인덱스 연산자 (operator[]
)는 표현식에 lvalue와 rvalue를 모두 사용할 수 있다.
다음은 클래스의 기본적인 사용법을 보여주는 예이다.
int main() { IntArray x(20); // 20 개의 정수 for (int i = 0; i < 20; i++) x[i] = i * 2; // 원소 할당 for (int i = 0; i <= 20; i++) // 경계 범람 발생 cout << "At index " << i << ": value is " << x[i] << '\n'; }
먼저, 생성자로 객체를 만들어 20개의 int
를 넣는다. 객체 안에 저장된 원소들을 열람하고 할당할 수 있다. 첫 번째 for
-회돌이에서 인덱스 연산자를 사용하여 값을 원소에 할당한다. 두 번째 for
-회돌이는 값을 열람하는데, 존재하지 않는 값 x[20]
에 접근하면 그 순간 실행 시간 에러를 일으킨다. IntArray
클래스의 인터페이스는 다음과 같다.
#include <cstddef> class IntArray { int *d_data; size_t d_size; public: IntArray(size_t size = 1); IntArray(IntArray const &other); ~IntArray(); IntArray const &operator=(IntArray const &other); // 중복정의 인덱스 연산자: int &operator[](size_t index); // 첫 번째 int const &operator[](size_t index) const; // 두 번째 void swap(IntArray &other); // 간이 멤버 private: void boundary(size_t index) const; int &operatorIndex(size_t index) const; };
이 클래스는 다음과 같은 특징이 있다.
size_t
매개변수에 1이 기본 인자 값으로 주어져 있는 생성자가 있다. 이 매개변수에 객체 안 int
개의 원소를 지정한다.
첫 번째 중복정의 인덱스 연산자로 비-상수 IntArray
객체의 원소에 접근해 변경할 수 있다. 이 연산자의 원형은 int
를 참조로 돌려주는 함수이다. 이 덕분에 x[10]
와 같은 표현식을 rvalue 또는 lvalue로 사용할 수 있다.
그러므로 비-상수 IntArray
객체에 operator[]
를 사용하여 값을 할당하고 열람할 수 있다. 비-상수 operator[]
멤버의 반환 값은 int const &
가 아니라 int &
이다. 여기에서는 const
를 사용하지 않는다. 연산자가 lvalue로 사용될 때 접근하고 싶은 원소를 변경할 수 있어야 하기 때문이다.
이 전체 구도는 아무 것도 할당하지 않으면 실패한다. IntArray const stable(5)
가 있다고 생각해 보자. 이 객체는 변경불가 상수 객체이다. 컴파일러는 이를 탐지하고 이 객체의 정의를 컴파일하기를 거부한다. 비-상수 operator[]
를 사용할 수 있을 경우에만 컴파일을 허용한다. 그래서 두 번째 중복정의 인덱스 연산자를 이 클래스의 인터페이스에 추가한다. 여기에서 반환 값은 int const &
이지 int &
가 아니다. 멤버 함수 자체는 const
멤버 함수이다. 컴파일러는 이 두 번째 형태의 중복정의 인덱스 연산자를 const
객체에만 사용한다. 값을 할당하는 대신에 값을 열람하는 데에 사용된다. 물론 그것이 바로 const
객체를 사용할 때 정확하게 의도한 것이다. 이 상황에서 멤버는 const
속성으로만 중복정의된다. 이 형태의 함수 중복정의는 이전에 소개한 바 있다 (2.5.4항과 7.7절 참고).
두 개의 중복정의 인덱스 연산자 int &
와 int
의 반환 유형이 서로 다름에 유의하자. 여기에서 불변 인덱스 연산자에는 값을 반환 유형으로 사용해도 좋다. 왜냐하면 반환 유형이 단순히 내장 유형이기 때문이다. 요청된 값을 상수 참조로 반환하는 것보다 사본으로 반환하는 것이 좀 더 효율적이다. 왜냐하면 상수 참조는 사용할 때마다 역참조해야 하기 때문이다. 그렇지만 좀 복잡한 반환 유형(객체 유형)이라면 값으로 돌려주는 것은 피하는 것이 좋다. 불변 인덱스 연산자는 Type
값 대신에 Type const &
를 반환 유형으로 정의해야 한다.
IntArray
객체는 원시 유형의 값을 저장하고 있으므로 IntArray
의 operator[] const
에는 반환 유형도 정의할 수 있었다. 그렇지만 객체에 반환 유형의 값으로 암시되는 복사를 추가하고 싶지는 않다. 그런 경우 const
멤버 함수에는 const &
반환 값이 더 좋다. 그래서 IntArray
클래스에서 int
반환 값을 사용할 수도 있었고 그러면 원형은 다음과 같을 것이다.
int IntArray::operator[](size_t index) const;
delete[] data
로 소멸된다.
swap
함수의 구현은 생략한다. 제 9장):
#include "intarray.ih" IntArray::IntArray(size_t size) : d_size(size) { if (d_size < 1) throw string("IntArray: size of array must be >= 1"); d_data = new int[d_size]; } IntArray::IntArray(IntArray const &other) : d_size(other.d_size), d_data(new int[d_size]) { memcpy(d_data, other.d_data, d_size * sizeof(int)); } IntArray::~IntArray() { delete[] d_data; } IntArray const &IntArray::operator=(IntArray const &other) { IntArray tmp(other); swap(tmp); return *this; } int &IntArray::operatorIndex(size_t index) const { boundary(index); return d_data[index]; } int &IntArray::operator[](size_t index) { return operatorIndex(index); } int const &IntArray::operator[](size_t index) const { return operatorIndex(index); } void IntArray::boundary(size_t index) const { if (index < d_size) return; ostringstream out; out << "IntArray: boundary overflow, index = " << index << ", should be < " << d_size << '\n'; throw out.str(); }
operator[]
멤버가 어떻게 구현되었는지 눈여겨보라: 비-상수 멤버가 상수 멤버 함수를 호출할 수도 있고 const
멤버 함수의 구현이 비-멤버 함수의 구현과 동일하기 때문에 둘 모두 operator[]
멤버는 인라인으로 정의할 수 있다. int &operatorIndex(size_t index) const
보조 함수를 사용하면 된다. const
멤버 함수는 객체의 데이터 멤버 중 하나를 가리키는, 비-상수 참조 (또는 포인터)를 돌려줄 가능성이 있다. 물론 이것은 잠재적으로 위험한 뒷문이다. 데이터 은닉의 원칙에 어긋난다. 그렇지만 이 결함을 공개 인터페이스의 멤버들이 메워준다. 그래서 두 개의 공개 operator[]
멤버는 같은 int &operatorIndex() const
멤버를 안전하게 호출할 수 있다. 비밀 통로가 정의되어 있더라도 말이다.
std::ostream
이나 std::istream
에 삽입하거나 추출할 수 있다.
std::ostream
클래스에는 int
와 char *
등등과 같은 원시 유형에 대하여 삽입 연산자가 정의되어 있다. 이 절에서는 역사적으로 나중에 개발된 클래스와 함께 조합하여 사용할 수 있도록 기존 클래스의 (특히 std::istream
과 std::ostream
의) 기능을 확장하는 법을 배운다.
삽입 연산자를 중복정의하여 객체의 유형에 상관없이 (제 9장) Person
과 같은 객체를 어떻게 ostream
안으로 삽입할 수 있는지 보여주겠다. 삽입 연산자를 중복정의하면 다음 코드를 사용할 수 있다.
Person kr("Kernighan and Ritchie", "unknown", "unknown"); cout << "Name, address and phone number of Person kr:\n" << kr << '\n';
cout
<< kr
서술문은 operator<<
를 사용한다. 이 멤버 함수는 피연산자로 ostream &
과 Person &
두 개가 있다. 필요한 조치는 중복정의 자유 함수 operator<<
안에 정의된다. 인자를 두 개 기대한다.
// `person.h'에 정의됨 std::ostream &operator<<(std::ostream &out, Person const &person); // 어떤 소스 파일에 정의됨 ostream &operator<<(ostream &out, Person const &person) { return out << "Name: " << person.name() << ", " "Address: " << person.address() << ", " "Phone: " << person.phone(); }
operator<<
자유 함수는 다음의 주목할 만한 특징이 있다.
ostream
객체를 참조로 돌려준다. 삽입 연산자를 `사슬로' 엮기 위해서이다.
operator<<
의 두 피연산자를 자유 함수에 인자로 건넨다. 예제에서 out
매개변수는 cout
으로 초기화되었고 person
매개변수는 kr
로 초기화되었다.
Person
클래스에 추출 연산자를 중복정의하려면 클래스의 데이터 멤버를 변경할 멤버 함수가 필요하다. 데이터 멤버변경자들은 일반적으로 클래스 인터페이스로 제공한다. Person
클래스의 데이터를 변경하는 멤버 함수는 다음과 같다.
void setName(char const *name); void setAddress(char const *address); void setPhone(char const *phone);이 멤버들은 쉽게 구현할 수 있다. 상응하는 데이터 멤버가 가리키는 메모리는 제거되어야 한다. 그리고 그 데이터 멤버는 매개변수가 가리키는 텍스트 사본을 가리켜야 한다.
void Person::setAddress(char const *address) { delete[] d_address; d_address = strdupnew(address); }더 정교한 함수라면 새 주소가 합당한지 점검해야 한다 (
address
도 0-포인터가 되면 안된다). 그렇지만 여기에서는 더 이상 추적하지 않겠다. 대신에 마지막 operator>>
를 살펴보자. 간단하게 구현하면 다음과 같다.
istream &operator>>(istream &in, Person &person) { string name; string address; string phone; if (in >> name >> address >> phone) // 다음 세 가지를 추출한다. { person.setName(name.c_str()); person.setAddress(address.c_str()); person.setPhone(phone.c_str()); } return in; }여기에서 따르고 있는 단계별 접근법을 주목하라. 먼저, 추출 연산자를 사용하여 필요한 정보를 추출한다. 다음, 추출에 성공하면 추출된 객체의 데이터 멤버를 변경자를 사용하여 변경한다. 마지막으로, 스트림 객체 자체를 참조로 돌려준다.
String
클래스는 char *
유형을 둘러싸 생성된다. 그런 클래스는 할당을 비롯하여 모든 종류의 연산을 정의할 수 있다. 다음 클래스 인터페이스를 살펴보자. string
클래스를 따라 설계했다.
class String { char *d_string; public: String(); String(char const *arg); ~String(); String(String const &other); String const &operator=(String const &rvalue); String const &operator=(char const *rvalue); };이 클래스의 실체는
char const *
로 초기화할 수 있고 또 String
자체로 초기화할 수도 있다. 중복정의 할당 연산자가 있어서 String
실체와 char const *
로 할당할 수 있다 (덕분에 널-포인터도 허용한다는 것을 눈여겨보라. stringObject = 0
과 같은 할당도 완벽하게 작동한다.).
char const *String::c_str() const
멤버와 같은 접근 멤버 함수는 보통 이 String
클래스보다 데이터에 덜 직접적으로 연결된 클래스에 들어간다. 그렇지만 StringArray
로 String
객체 배열을 정의할 때 이 후자의 멤버를 사용하는 것은 직관적으로 꺼림직하다. 이 후자의 클래스가 개별적으로 String
멤버에 접근하기 위하여 operator[]
가 있다면 적어도 다음의 클래스 인터페이스가 있을 가능성이 높다.
class StringArray { String *d_store; size_t d_n; public: StringArray(size_t size); StringArray(StringArray const &other); StringArray const &operator=(StringArray const &rvalue); ~StringArray(); String &operator[](size_t index); };이 인터페이스로
String
원소를 서로 할당할 수 있다.
StringArray sa(10); sa[4] = sa[3]; // String 대 String 할당그러나
char const *
를 sa
의 원소에 할당하는 것도 가능하다.
sa[3] = "hello world";여기에서 다음 단계를 밟는다.
sa[3]
을 평가한다. String
참조가 그 결과이다.
String
클래스에 중복정의 할당 연산자가 있는지 조사한다. 이 연산자는 오른쪽에 char const *
가 있기를 기대한다. 이 연산자가 발견된다. sa[3]
문자열 객체가 새 값을 받는다.
sa[3]
에 저장된 char const *
에 어떻게 접근할까? 다음 시도는 실패한다.
char const *cp = sa[3];클래스는 '
char const *
'에 중복정의 할당 연산자가 필요하기 때문에 실패한다. 불행하게도 그런 클래스는 없다. 그러므로 중복정의 할당 연산자를 빌드할 수 없다 (11.13절). 게다가 유형변환조차 작동하지 않는다. 컴파일러가 String
을 char const *
유형으로 변환하는 방법을 모르기 때문이다. 어떻게 처리할까?
한 가지 가능성은 접근자 c_str()
멤버 함수를 정의하는 것이다.
char const *cp = sa[3].c_str()컴파일은 잘 되지만 보기에 어색하다. 더 좋은 접근법은 변환 연산자를 사용하는 것이다.
변환 연산자는 일종의 중복정의 연산자이지만 이 번에는 중복정의를 사용하여 객체를 다른 유형으로 변환한다. 클래스 인터페이스에 보이는 변환 연산자의 모습은 보통 다음과 같다.
operator <type>() const;변환 연산자는
const
멤버 함수이다. 자신의 객체가 lvalue 유형의 표현식에서 rvalue로 사용될 때 자동으로 호출된다. 변환 연산자를 사용하면 String
객체는 char const *
rvalue로 번역될 수 있으며 그리하여 위의 할당을 수행할 수 있다.
변환 연산자는 좀 위험하다. 변환은 컴파일러가 자동으로 수행한다. 그리고 그의 사용법이 완벽하게 투명하지 않는 한, 변환 연산자가 사용된 코드는 읽는 사람에게 혼동을 야기할 수 있다. 예를 들어 초보 C++ 프로그래머는 다음과 같은 서술문에 자주 혼란을 겪는다. `if (cin) ...
'.
제일 규칙으로서 클래스는 변환 연산자를 최대 한 개만 정의해야 한다. 변환 연산자를 여러 개 정의할 수는 있지만 코드에 모호함이 생기는 경우가 잦다. 예를 들어 클래스가 operator bool() const
과 operator int() const
를 정의했을 경우, size_t
인자를 기대하는 함수에 이 클래스의 실체를 건네면 모호함이 생긴다. size_t
를 초기화하는 데 int
와 bool
이 모두 사용될 수 있기 때문이다.
현재 예제에서 String
클래스는 char const *
에 다음 변환 연산자를 정의할 수 있다.
String::operator char const *() const { return d_string; }요약하면:
operator
키워드 다음에 돌려준다.
String
인자를 건네면) 컴파일러는 그 의도를 명확하게 알기 위하여 도움이 필요하다. static_cast
가 문제를 해결해 준다.
X::operator std::string const() const
변환 연산자를 정의하면 cout << X()
는 컴파일되지 않는다. 그 이유는 21.9절에 설명한다. 그러나 변환 연산자를 신속하게 작동시키려면 다음의 중복정의 operator<<
함수를 정의하면 된다.
std::ostream &operator<<(std::ostream &out, std::string const &str) { return out.write(str.data(), str.length()); }
X
클래스에 변환 연산자가 정의되어 있으면 역시 X
객체를 받는 삽입 연산자를 정의한다.
Insertable
클래스의 실체는 직접적으로 삽입된다. Convertor
클래스의 실체는 변환 연산자를 사용한다. Error
클래스의 실체는 삽입이 불가능하다. 삽입 연산자를 정의하고 있지 않으며 자신의 변환 연산자가 돌려준 유형도 삽입이 불가능하기 때문이다 (Text
는 operator int() const
를 정의한다. 그러나 Text
자체를 삽입할 수 없기 때문에 에러가 일어난다):
#include <iostream> #include <string> using namespace std; struct Insertable { operator int() const { cout << "op int()\n"; } }; ostream &operator<<(ostream &out, Insertable const &ins) { return out << "insertion operator"; } struct Convertor { operator Insertable() const { return Insertable(); } }; struct Text { operator int() const { return 1; } }; struct Error { operator Text() const { return Text(); } }; int main() { Insertable insertable; cout << insertable << '\n'; Convertor convertor; cout << convertor << '\n'; Error error; cout << error << '\n'; }
변환 연산자에 관하여 마지막으로 몇 마디 남긴다.
operator bool()
를 정의하고 있는데, if (cin)
과 같은 생성을 허용한다.
operator int &()
변환 연산자를 정의하는 것은) 뒷문을 활짝 열어 두는 것이다. 그리고 그 연산자는 명시적으로 호출될 때 (x.operator int&() = 5
와 같이) lvalue로만 사용할 수 있다. 그냥 사용하지 마라.
const
멤버 함수로 정의해야 한다. 자신의 객체의 데이터 멤버를 변경하지 않기 때문이다.
DataBase
라는 데이터베이스 클래스가 정의되어 있고 안에 Person
실체를 저장할 수 있다고 가정하자. Person *d_data
포인터가 정의되어 있고 그래서 복사 생성자와 중복정의 할당 연산자를 제공한다.
복사 생성자 말고도 DataBase
는 기본 생성자와 여러 생성자를 더 제공한다.
DataBase(Person const &)
: DataBase
는 처음에 Person
실체가 하나만 담겨 있다.
DataBase(istream &in)
: 여러 사람에 관한 데이터를 in
으로부터 읽는다.
DataBase(size_t count, istream &in = cin)
: count
명의 사람에 대한 데이터를 in
으로부터 읽는다. 기본 값은 표준 입력 스트림이다.
위의 생성자는 모두 완벽하게 합리적이다. 그러나 컴파일러는 다음과 같은 코드를 아무 경고조차 없이 컴파일해 버린다.
DataBase db; DataBase db2; Person person; db2 = db; // 1 db2 = person; // 2 db2 = 10; // 3 db2 = cin; // 4서술문 1은 완벽하게 합리적이다.
db
로 db2
를 재정의한다. 서술문 2는 이해할 만하다. DataBase
에 Person
실체가 담기도록 설계했기 때문이다. 그럼에도 여기에 사용된 로직에 의문을 제기할 수 있다. Person
은 DataBase
의 종류가 아니기 때문이다. 서술문 3과 4를 보면 로직은 더욱 더 불투명해진다. 서술문 3은 10명의 데이터가 표준 입력 스트림에 나타나기를 기다리는 효과가 있다. db2 = 10
의 모양에서 그런 암시는 전혀 찾아 볼 수 없다.
네 개의 서술문 모두 묵시적으로 승격된 결과이다. 각각 Person
과 istream
그리고 size_t
를 받는 생성자와 istream
이 DataBase
에 정의되어 있고 그리고 할당 연산자는 오른쪽 인자로 (rhs) DataBase
를 기대하기 때문에 컴파일러는 먼저 rhs 인자들을 익명의 DataBase
객체로 변환한다. 그 다음에 그 실체들을 db2
에 할당한다.
생성자를 선언할 때 explicit
수식자를 사용하여 묵시적인 승격을 방지하는 것은 좋은 관례이다. explicit
수식자를 사용하는 생성자는 객체를 명시적으로만 생성할 수 있다. 인자를 하나 기대하는 생성자가 explicit
키워드로 선언되어 있지 않으면 서술문 2-4는 컴파일되지 않을 것이다. 예를 들어,
explicit DataBase(Person const &person); explicit DataBase(size_t count, std:istream &in);
인자 하나를 명시적(explicit
)으로 받도록 모든 생성자를 선언했다면 위의 할당은 적절한 생성자를 명시적으로 지정하기를 요구했을 것이다. 그리하여 프로그래머의 의도를 밝히면:
DataBase db; DataBase db2; Person person; db2 = db; // 1 db2 = DataBase(person); // 2 db2 = DataBase(10); // 3 db2 = DataBase(cin); // 4제일 규칙으로서 인자가 하나뿐인 생성자 앞에는
explicit
키워드를 붙여라. 묵시적인 승격이 완벽하게 자연스럽지 않는 한 말이다 (string
의 char const *
을 받는 생성자가 그 한 예이다).
예를 들어, 클래스는 operator bool() const
를 정의할 수 있다. 그 클래스의 실체가 사용 가능한 상태이면 true
를 돌려주고 그렇지 않으면 false
를 돌려준다. bool
유형은 산술 유형이기 때문에 예상치 못한 행위를 초래할 수 있다. 다음을 생각해 보자:
class StreamHandler { public: operator bool() const; // true: 객체를 사용하기에 적합함 ... }; int fun(StreamHandler &sh) { int sx; if (sh) // operator bool()의 원래 사용 의도 ... 평소처럼 sh를 사용; `sx'도 사용 process(sh); // 오타: 원래는 `sx'를 사용할 생각이었다. }이 예제에서
process
는 의도치 않게 operator bool
이 돌려준 값을 받는다. bool
을 int
로 묵시적으로 변환해서 말이다.
explicit
변환 연산자라면 위의 예제에 보여준 묵시적인 변환은 금지된다. 변환된 유형이 명시적으로 요구될 경우에만 그런 변환 연산자들을 사용할 수 있다. 예를 들어 bool
값을 기대하는 if
조건 서술문 또는 반복 서술문의 경우라면 explicit operator bool()
변환 연산자가 자동으로 사용될 것이다.
operator++
)와 감소 연산자(operator
--)는 좀 복잡하다. 각 연산자마다 두 가지 버전이 있는데 각각 후위 연산자 (x++
)나 전위 연산자 (++x
)로 사용될 수 있기 때문이다.
후위 연산자로 사용되면 먼저 값의 실체가 임시의 상수 실체인 rvalue로 반환된다. 그 다음에 증가된 변수 자체는 시야로부터 사라진다. 전위 연산자로 사용되면 먼저 변수가 증가하고 다음에 그의 값이 lvalue로 반환된다. 이 값은 전위 연산자의 반환 값을 변경하면 다시 또 바뀔 수 있다. 연산자를 중복정의할 때 이 특징들은 필수는 아니다. 그러나 중복정의 증감 연산자에 이 특징을 구현하기를 적극 권고한다.
size_t
값 유형을 둘러싼 포장 클래스를 정의했다고 가정하자. 그런 클래스는 다음 인터페이스를 제공할 수 있다 (일부만 보여줌):
class Unsigned { size_t d_value; public: Unsigned(); explicit Unsigned(size_t init); Unsigned &operator++(); }클래스의 마지막 멤버에 중복정의 전위 증가 연산자가 정의되어 있다. 반환된 lvalue는
Unsigned &
이다. 멤버 구현은 아주 쉽다.
Unsigned &Unsigned::operator++() { ++d_value; return *this; }
후위 연산자를 정의하기 위해 해당 연산자의 중복정의 버전을 정의한다. (더미) int
인자를 기대한다. 이것을 조잡한 인터페이스(kludge)로 간주하거나 또는 받아들일 만한 함수 중복정의의 적용으로 간주해도 좋다. 이 문제를 어떻게 생각하든 다음과 같이 결론을 내릴 수 있다.
Unsigned
클래스의 인터페이스에 선언된다.
Unsigned operator++(int);다음과 같이 구현해도 된다.
Unsigned Unsigned::operator++(int) { Unsigned tmp(*this); ++d_value; return tmp; }매개변수 int가 사용되지 않고 있음을 눈여겨보라. 구현과 선언에서 전위 연산자와 후위 연산자를 그저 구별하는 용도로 사용될 뿐이다.
위의 예제에서 현재 실체를 증가시키는 서술문은 절대(nothrow) 보장을 제공한다. 원시 유형의 연산에만 관련되어 있기 때문이다. 최초의 복사 생성에서 예외를 던지더라도 원래의 실체는 변경되지 않는다. return 서술문이 예외를 던지더라도 그 실체는 안전하게 변경된다. 그러나 실체를 증가시키는 것은 그 자체로 예외를 던질 가능성이 있다. 그 경우에 증가 연산자를 어떻게 구현할 것인가? 다시 한 번 swap
이 우리의 친구가 된다. 다음은 증가 연산을 수행하는 increment
멤버가 예외를 던질 때 강력 보장을 제공하는 전위 연산자와 후위 연산자이다.
Unsigned &Unsigned::operator++() { Unsigned tmp(*this); tmp.increment(); swap(tmp); return *this; } Unsigned Unsigned::operator++(int) { Unsigned tmp(*this); tmp.increment(); swap(tmp); return tmp; }두 연산자 모두 먼저 현재 객체의 사본을 만든다. 이 사본을 증가시킨 다음 현재 객체와 교환한다. 후위 증가 연산자는 먼저 현재 실체의 사본을 만든다. 그 사본을 증가시키고 현재 실체와 교체한다.
increment
멤버가 예외를 던지더라도 현재 실체는 바뀌지 않은 채로 그대로이다. 교체 연산은 원래 실체가 (전위 연산자라면 증가된 객체가 그리고 후위 연산자라면 원래 객체가) 반환되고 현재 실체가 증가된 실체임을 확인해 준다.
멤버 함수 이름을 완전하게 사용하여 증감 연산자를 호출하면 그 함수에 건네어진 int
인자는 결과적으로 후위 연산자를 호출한다. 인자를 생략하면 결과는 전위 연산자를 호출하는 것이다. 예제:
Unsigned uns(13); uns.operator++(); // uns 전위-증가 uns.operator++(0); // uns 후위-증가
전위 증감 연산자와 후위 증감연산자는 bool
유형의 변수에 적용할 때는 추천하지 않는다. 단, std::exchange
를 사용해야 할 경우라면 후위 증가 연산자가 유용하다 (19.1.11항).
operator+
와 같은) 이항 연산자를 중복정의하면 아주 자연스럽게 클래스의 기능을 확장할 수 있다. 예를 들어 std::string
클래스는 다양한 형태의 operator+
가 있다.
대부분의 이항 연산자는 평범한 이항 연산자(+
연산자)와 반영 할당 연산자 변형 (+=
연산자)의 두 가지 형태가 있다. 평범한 이항 연산자는 값을 돌려주는 반면에 반영 할당 연산자는 연산자가 적용된 실체를 참조로 돌려준다. 예를 들어 std::string
객체로 다음 코드를 사용할 수 있다 (아래 예제에 주해를 달아 놓았다):
std::string s1; std::string s2; std::string s3; s1 = s2 += s3; // 1 (s2 += s3) + " postfix"; // 2 s1 = "prefix " + s3; // 3 "prefix " + s3 + "postfix"; // 4
// 1
에서 s3
의 내용이 s2
에 더해진다. 다음, s2
가 반환된다. 그리고 그의 새로운 내용이 s1
에 할당된다. +=
는 s2
자체를 돌려줌에 주목하라.
// 2
에서 s3
의 내용은 s2
에 더해진다. 그러나 +=
는 s2
자체를 돌려주기 때문에, s2
에 조금 더 더하는 것도 가능하다.
// 3
에서 +
연산자는 std::string
을 돌려준다. 안에 텍스트 prefix
와 s3
의 내용이 결합되어 들어 있다. +
연산자가 돌려준 이 문자열은 그 다음 s1
에 할당된다.
// 4
에서 +
연산자가 두 번 적용된다. 그 효과는 다음과 같다.
+
는 std::string
을 돌려준다. 안에 텍스트 prefix
와 s3
의 내용이 결합되어 들어 있다.
+
연산자는 이렇게 반환된 문자열을 자신의 왼쪽 값으로 취하고 왼쪽과 오른쪽 피연산자에 담긴 텍스트를 결합한 문자열을 돌려준다.
+
연산자가 반환한 문자열이 표현식의 값을 대표한다.
다음 코드를 연구해 보자. Binary
클래스에 중복정의 operator+
연산자가 있다.
class Binary { public: Binary(); Binary(int value); Binary operator+(Binary const &rvalue); }; int main() { Binary b1; Binary b2(5); b1 = b2 + 3; // 1 b1 = 3 + b2; // 2 }
이 작은 프로그램은 서술문// 2
에서 컴파일에 실패한다. 다음과 같은 컴파일 에러를 보고한다.
error: no match for 'operator+' in '3 + b2' 에러: '3 + b2' 표현식에 'operator+'가 부합하지 않음왜 서술문
// 1
은 올바르게 컴파일되는데 서술문 // 2
는 컴파일되지 않는가?
이를 이해하려면 승격(promotions)을 떠올리자. 11.4절에서 보았듯이 단 한 개의 인자를 기대하는 생성자는 적절한 유형의 인자가 공급되면 묵시적으로 활성화된다. 이것을 std::string
객체에서 반복적으로 보아 왔다. NTBS를 사용해 std::string
객체를 초기화할 수 있다.
비슷하게, 서술문 // 1
에서 +
연산자가 b2
객체에 대하여 호출된다. 이 연산자는 또다른 Binary
객체를 오른쪽 피연산자로 기대한다. 그렇지만 int
가 공급된다. 생성자 Binary(int)
가 존재하기 때문에 int
값이 먼저 Binary
객체로 승격된다. 다음, 이 Binary
객체가 인자로 operator+
멤버에 건네진다.
서술문 // 2
에는 승격을 사용할 수 없다. 여기에서 int
인 lvalue에 +
연산자가 적용된다. int
는 원시 유형이다. 원시 유형은 `생성자'나 `멤버 함수' 또는 `승격'의 개념이 없다.
그러면 어떻게 서술문 "prefix " + s3
과 같이 왼쪽 피연산자의 승격을 구현하는가? 승격은 함수 인자에 적용되기 때문에 이항 연산자의 피연산자 두 개가 모두 인자임을 확인해야 한다. 이것은 평범한 이항 연산자가 왼쪽 피연산자나 오른쪽 피연산자에 승격을 지원하려면 자유 연산자로 선언해야 한다는 것을 의미한다. 이것을 자유 함수라고도 부른다.
평범한 이항 연산자와 같은 함수들은 개념적으로 이항 연산자를 구현한 클래스에 속한다. 결과적으로 클래스의 헤더 파일에 선언되어야 한다. 그 구현 방법을 잠시 후에 다루어 보겠다. 다음은 Binary
클래스 선언의 첫 번째 버전이다. 중복정의 +
연산자를 자유 함수로 선언한다.
class Binary { public: Binary(); Binary(int value); }; Binary operator+(Binary const &lhs, Binary const &rhs);
이항 연산자를 자유 함수로 선언함으로써 다음의 승격이 가능해 진다.
class A; class B { public: B(A const &a); }; class A { public: A(); A(B const &b); }; A operator+(A const &a, B const &b); B operator+(B const &b, A const &a); int main() { A a; a + a; };
여기에서 a + a
를 컴파일할 때 중복정의 +
연산자 둘 모두 가능하다. 모호성은 명시적으로 인자 중 하나를 승격시켜서 해결해야 한다. 예를 들어, a + B(a)
로 승격하면 컴파일러는 첫 번째 중복정의 +
연산자로 모호성을 해결할 수 있다.
다음 단계는 이에 상응하는 중복정의 이항 반영 할당 연산자를 구현하는 것이다. 형태는 @=
와 같고 @
는 이항 연산자를 나타낸다. 이 연산자는 언제나 왼쪽 피연산자가 있는데 자신의 클래스의 실체이므로 진짜 멤버 함수로 구현된다. 게다가 반영 할당 연산자는 이항 연산을 적용할 객체를 참조로 돌려주어야 한다. 객체를 같은 서술문에서 변경할 가능성이 있기 때문이다. (s2 += s3) + " postfix"
와 같이 말이다. 다음은 Binary
클래스의 두 번째 수정본이다. 평범한 이항 연산자의 선언과 그에 상응하는 반영 할당 연산자를 모두 보여준다.
class Binary { public: Binary(); Binary(int value); Binary &operator+=(Binary const &rhs); }; Binary operator+(Binary const &lhs, Binary const &rhs);
어떻게 반영 덧셈 할당 연산자를 구현해야 하는가? 반영 할당 연산자를 구현할 때 강력 보장을 다시 또 염두에 두어야 한다. 임시 객체를 사용하고 add
멤버가 예외를 던지면 서로 교체하라. 예를 들어:
Binary &operator+=(Binary const &other) { Binary tmp(*this); tmp.add(other); // 여기에서 예외를 던질 가능성이 있다. swap(tmp); return *this; }
부합하는 반영 할당 연산자가 있는 클래스에 평범한 이항 연산자를 구현하는 것은 어렵지 않다. lhs
인자는 Binary tmp
안으로 복사되고 거기에 rhs
피연산자가 더해진다. 다음, tmp
가 반환된다. 복사 생성과 두 개의 서술문은 단 하나의 return 서술문으로 줄일 수는 있지만 그러면 컴파일러는 복사 생략을 적용할 수 없다. 복사 생략은 따로따로 처리할 때 사용된다:
class Binary { public: Binary(); Binary(int value); Binary &operator+=(Binary const &other); }; Binary operator+(Binary const &lhs, Binary const &rhs) { Binary tmp(lhs); tmp += rhs; return tmp; }
그러나 잠깐! 9.7.8항에 다루었던 이동-인지 클래스에 대한 설계 원칙이 기억나시는지? 그 설계 원칙에 언급된 것을 정확하게 똑 같이 이항 연산자에 구현해 보자. 임시 객체를 생성하고 반영 할당 연산을 그 임시 객체에 적용한다. 다음 항에서 이 설계 원칙을 어떻게 장점으로 이용할 수 있는지 살펴 보겠다.
Binary
클래스는 이동-인지 클래스이다. 그래서 이동-인지 이항 연산자를 거기에 추가할 수 있다. 실제 작업은 언급했듯이 반영 덧셈 할당 연산자로 수행된다. (두 개의 상수 참조를 받는) 전통적인 이항 연산자의 형식을 이동-인지 덧셈 연산자에 적용하면 그 서명은 다음과 같다.
Binary operator+(Binary &&lhs, Binary const &rhs);이를 구현하기 위하여 이미 임시 객체가 있고 그래서
rhs
를 거기에 더한 후에 lhs
를 돌려줄 수 있다. lhs
는 이미 임시 객체이다. 반영 덧셈을 std::move
호출에 싸 넣으면 복사 생성을 피할 수 있다.
Binary operator+(Binary &&lhs, Binary const &rhs) { return std::move(lhs += rhs); }(모두
Binary
객체) b1 + b2 + b3
표현식을 실행하면 다음 함수들이 호출된다.
copy operator+ = b1 + b2 Copy constructor = tmp(b1) Copy += = tmp += b2 Copy constructor = tmp2(tmp) += operation = tmp2.add(b3), swap(tmp2) move operator+ = tmp + b3 Copy += = tmp += b3 Copy constructor = tmp2(tmp) += operation = tmp2.add(b3), swap(tmp2) Move constructor = return std::move(tmp)적어도 약간의 이득이 있다.
std::move
포장함수를 생략하면 복사 생성자가 호출된다.
그러나 이미 임시 객체가 있기 때문에 컴파일러에게 반환 값 최적화를 사용하도록 유도한다면 좋지 않을까? 가능하다. 컴파일러에게 operator+
의 반환 값이 임시 객체라는 사실을 알려 주면 된다. 그의 반환 값이 rvalue 참조라고 명시적으로 서술해서 알린다.
Binary &&operator+(Binary &&lhs, Binary const &rhs) { return std::move(lhs += rhs); }`전통적인' 이항 연산자도 임시 객체를 반환한다는 사실을 깨닫았으므로 다음 연산자에도 같은 일을 할 수 있다.
Binary &&operator+(Binary const &lhs, Binary const &rhs) { Binary tmp(lhs); return std::move(tmp += rhs); }이제 컴파일러는 반환 값 최적화를 적용하고 임시 값을 돌려준다. 새로 객체를 생성하지 않는다. 이동 생성자의 마지막 호출은 사라진다.
그러나 아직 끝나지 않았다. 흥미로운 최적화의 가능성을 다음 항에서 몇가지 더 만나 보겠다.
operator+
와 같은) 평범한 이항 연산자를 아주 효율적으로 구현할 수 있음을 보았다. 익명 객체에는 rvalue-참조 매개변수가 사용된다. 게다가 rvalue 참조를 반환 유형으로 지정하면 컴파일러에게 반환된 임시 객체에 대하여 반환 값 최적화를 사용하라고 요구할 수 있다.
그러나 이 경우 lhs
피연산자는 임시 객체이고 rhs
피연산자는 직접적으로 lhs
피연산자에 더해질 수 있으며 더 이상 operator+=
가 만든 임시 값을 필요로 하지 않는다.
그리하여 우리의 operator+=
구현은 다음과 같다.
Binary &operator+=(Binary const &rhs) { Binary tmp(*this); tmp.add(rhs); swap(tmp); return *this; }그렇지만
operator+
를 구현할 때 이미 임시 객체가 있거나 (operator+(Binary &&lhs, ...)
) 아니면 즉시 임시 객체를 생성한다 (operator+(Binary const &lhs, ...)
). 현재 구현은 전통적인 임시 객체가 많이 생성된다. 각 임시 객체는 다음과 같은 표현식에서 복사 생성을 요구한다.
Binary{} + varB + varC + varD
Binary{} + varB
를 계산할 때 임시 객체가 생성되고, 또 Binary{} + varC
에 대하여 또다른 임시 객체가 생성되고 또 Binary{} + varD
에 대하여 임시 개체가 또 생성된다. 게다가 덧셈마다 교체도 수행된다. 이미 임시 객체를 손 안에 확보하고 있어도 말이다 (즉, Binary{}
).
어떻게 컴파일러에게 이런 임시 객체가 필요하지 않다고 알려줄 것인가?
operator+=
를 표준의 lvalue가 (즉, 왼쪽의 피연산자가) 호출했는지 아니면 rvalue 참조가 (즉, 임시 객체가) 호출했는지 컴파일러에게 알려줄 방법이 필요하다 . 이것은 이른바 참조 수식자(reference qualifiers)라고 부르는 참조 묶기(reference bindings)를 사용하여 실현할 수 있다. 중복정의 연산자는 물론이고 모든 멤버 함수가 사용할 수 있는 참조 묶기는 참조 토큰 (&
) 또는 rvalue 참조 토큰 (&&
)으로 구성된다. 이 토큰은 함수 머리부에 바로 붙어 있다 (이것은 선언과 구현에 똑 같이 적용된다). 익명의 임시 객체가 호출할 때는 rvalue 참조 묶기가 있는 함수가 사용된다 (즉, rvalue). 반면에 다른 유형의 객체가 호출할 때는 lvalue 참조 묶기가 있는 함수가 사용된다. 적절한 곳에 const
수식자를 추가로 적용할 수 있다 (물론 rvalue 참조 묶기와 조합해 사용하면 의미가 없을 것이다. rvalue 참조는 const
객체를 참조하지 않기 때문이다).
이제 operator+()
의 구현을 세심하게 다듬어 보자. 먼저 임시 객체로부터 호출될 때의 operator+=
와 또다른 객체로부터 호출될 때의 operator+=
를 구별한다. 후자의 경우 임시 객체가 필요하다. 거기에 rhs
가 더해진다.
Binary &operator+=(Binary const &rhs) && { // 직접적으로 rhs를 *this에 더한다, return *this; } Binary &operator+=(Binary const &rhs) & { Binary tmp(*this); std::move(tmp) += rhs; // 직접적으로 rhs를 tmp에 더한다. swap(tmp); return *this; }
이제 operator+
의 구현 두 개를 살펴 보자. Binary &&lhs
를 사용할 때 직접적으로 operator++() &&
를 호출할 수 있다. 그렇지 않으면 먼저 임시 객체를 만든 다음에 그 임시 객체로부터 operator+=() &&
를 호출한다.
Binary &&operator+(Binary &&lhs, Binary const &rhs) { return move(move(lhs) += rhs); } Binary &&operator+(Binary const &lhs, Binary const &rhs) { Binary tmp(lhs); return move(tmp) + rhs; }그래서 왜 여전히
operator+=() &
가 필요한가? 기존의 Binary
객체에 무엇인가를 더하고 싶은 상황이라면 필요하다.
그리고 다음은 b1 + b2 + b3
표현식에 대하여 위의 구현을 사용할 때 호출되는 것이다.
Copy constructor = tmp(b1) Move += = tmp += b2 Move += = tmp += b3첫 번째 피연산자가 이미 임시 객체일 때 훨씬 더 빠르다 (즉,
Binary{} + b2 + b3
):
Move += = Binary{} += b2 Move += = Binary{} += b3전통적인 구현을 사용하여 이전 항에 보여준 것들과 이 행위를 비교했으면 더 선명하게 설명이 되었을 것이다.
요약하면:
operator+
처럼) 클래스의 기능을 아주 자연스럽게 확장할 수 있다. 예를 들어 std::string
클래스는 중복정의 operator+
멤버가 다양하게 있다.
대부분의 이항 연산자는 두 가지 형태가 있다. (+
연산자처럼) 평범한 이항 연산자와 (operator+=
연산자처럼) 반영 이항 할당 연산자가 있다. 평범한 이항 연산자는 값을 돌려주는 반면에 반영 이항 할당 연산자는 자신을 호출한 객체를 참조로 돌려준다. 예를 들어 std::string
객체에 다음 코드를 사용할 수 있다 (예제 아래에 주석 참고):
std::string s1; std::string s2; std::string s3; s1 = s2 += s3; // 1 (s2 += s3) + " postfix"; // 2 s1 = "prefix " + s3; // 3 "prefix " + s3 + "postfix"; // 4
// 1
에서 s3
의 내용이 s2
에 추가된다. 다음으로, s2
가 반환되고 그 내용이 s1
에 할당된다. +=
는 s2
자체를 돌려주는 것에 주목하자.
// 2
에서 s3
의 내용이 또 s2
에 추가된다. 그러나 +=
는 s2
자체를 돌려주므로 s2
에 더 추가할 수 있다.
// 3
에서 +
연산자는 std::string
을 돌려준다. 안에 prefix
텍스트와 s3
의 텍스트가 결합되어 들어 있다. +
연산자가 돌려준 이 문자열은 그 다음 s1
에 할당된다.
// 4
에서 +
연산자가 두 번 적용된다. 그 효과는 다음과 같다.
+
는 std::string
을 돌려주고 안에 prefix
텍스트와 s3
의 내용이 결합되어 들어 있다.
+
연산자는 돌려받은 이 문자열을 왼쪽 값으로 취하고 문자열에 왼쪽 피연산자와 오른쪽 피연산자의 텍스트를 결합해 넣어 반환한다.
+
연산자가 돌려준 문자열은 표현식의 값을 나타낸다.
다음 코드를 연구해 보자. Binary
클래스는 중복정의 operator+
를 지원한다.
class Binary { public: Binary(); Binary(int value); Binary operator+(Binary const &rhs); }; int main() { Binary b1; Binary b2(5); b1 = b2 + 3; // 1 b1 = 3 + b2; // 2 }
이 작은 프로그램을 컴파일하면 서술문 // 2
에서 실패한다. 다음과 같은 에러를 보고한다.
error: no match for 'operator+' in '3 + b2' 에러: '3 + b2'에서 'operator+'에 부합하는 것이 없음왜 서술문
// 1
은 올바르게 컴파일되는데 서술문 // 2
는 컴파일되지 않는가?
이것을 이해하려면 승격(promotions)을 기억해야 한다. 11.4절에서 보았듯이 인자를 하나만 기대하는 생성자는 적절한 유형의 인자가 건네지면 묵시적으로 활성화될 수 있다. 이미 이를 std::string
객체에서 만나보았다. NTBS 문자열을 사용하여 std::string
객체를 초기화할 수 있다.
유사하게 서술문 // 1
에서 operator+
연산자가 호출된다. b2
를 왼쪽 피연산자로 사용한다. 이 연산자는 또다른 Binary
객체를 오른쪽 피연산자로 기대한다. 그렇지만 int
가 건네진다. 그러나 Binary(int)
생성자가 있으므로 int
값이 Binary
객체로 승격되어 버릴 수 있다. 다음으로 이 Binary
객체가 인자로 operator+
멤버에 건네진다.
불행하게도 서술문 // 2
에서는 승격이 불가능하다. 여기에서 +
연산자가 int
-유형의 lvalue에 적용된다. int
는 원시 유형이고 원시 유형은 `생성자'라든가 `멤버 함수' 또는 `승격'을 전혀 알지 못한다.
그러면 서술문 "prefix " + s3
과 같이 어떻게 왼쪽 피연산자의 승격을 구현할까? 승격을 함수 인자에 적용할 수 있기 때문에 이항 연산자의 피연산자가 모두 인자라는 사실을 확인해야 한다. 이것은 평범한 이항 연산자는 왼쪽 피연산자나 오른쪽 피연산자에 승격을 지원하려면 자유 연산자(free operators), 이른바 자유 함수(free functions)로 선언해야 한다는 뜻이다.
평범한 이항 연산자 같은 함수들은 개념적으로 자신을 구현한 클래스에 속해 있다. 결론적으로 그 클래스의 헤더 파일에 선언되어야 한다. 잠시 후에 그 구현을 다루어 보겠지만, 여기에서는 먼저 Binary
클래스의 선언을 개선해 보자. 중복정의 +
연산자를 자유 함수로 선언한다.
class Binary { public: Binary(); Binary(int value); }; Binary operator+(Binary const &lhs, Binary const &rhs);
이항 연산자를 자유 함수로 정의하면 여러 가지 승격이 가능해 진다.
class A; class B { public: B(A const &a); }; class A { public: A(); A(B const &b); }; A operator+(A const &a, B const &b); B operator+(B const &b, A const &a); int main() { A a; a + a; };
여기에서 서술문 a + a
를 호출할 때 두 가지 중복정의 +
연산자는 모두 후보감이다. 모호성을 해결하려면 인자 중 하나를 명시적으로 승격해야 한다. 예를 들어 a + B{a}
와 같이 하면 컴파일러는 첫 번째 중복정의 +
연산자로 모호성을 해결한다.
다음 단계는 필요한 중복정의 반영 이항 할당 연산자를 구현하는 것이다. 모습은 @=
과 같으며 @
는 이항 연산자를 나타낸다. 이 연산자들은 언제나 왼쪽 피연산자가 자신의 클래스의 실체이므로 진짜 멤버 함수로 구현된다. 게다가 반영 이항 할당 연산자는 자신을 요청한 객체를 참조로 돌려주어야 한다. (s2 += s3) + " postfix"
와 같은 서술문에서 이 실체들이 수정될 가능성이 있기 때문이다.
.
다음은 Binary
클래스를 개정한 버전이다. 평범한 할당 연산자의 선언과 그에 상응하는 반영 할당 연산자의 선언을 모두 보여준다.
class Binary { public: Binary(); Binary(int value); Binary &operator+=(Binary const &rhs); }; Binary operator+(Binary const &lhs, Binary const &rhs);
어떻게 반영 덧셈 연산자를 구현해야 할까? 반영 이항 할당 연산자를 구현할 때 강력 보장을 언제나 염두에 두어야 한다. 연산 중에 예외를 던진다면 임시 객체를 사용하고 교환하라. 다음은 반영 할당 연산자를 구현한 것이다.
Binary &Binary::operator+=(Binary const &rhs) { Binary tmp(*this); tmp.add(rhs); // 여기에서 예외를 던질 가능성이 있다. swap(tmp); return *this; }
자유 이항 연산자를 손쉽게 구현할 수 있다. lhs
인자는 Binary tmp
에 복사되어 들어가고 거기에 rhs
피연산자가 더해진다. 그 다음에 tmp
가 반환된다. 복사 생략이 사용되었다. Binary
클래스는 자유 이항 연산자를 친구로 선언한다 (제 15장). 그래서 Binary
클래스의 add
멤버를 호출할 수 있다.
class Binary { public: Binary(); Binary(int value); Binary &operator+=(Binary const &other); private: void add(Binary const &other); friend Binary operator+(Binary const &lhs, Binary const &rhs); };
이항 연산자의 구현은 다음과 같다.
Binary operator+(Binary const &lhs, Binary const &rhs) { Binary tmp { lhs }; tmp.add(rhs); return tmp; }
Binary
클래스가 이동을 인지하면 이동 인지 이항 연산자를 추가하는 것이 좋다. 이 경우 왼쪽 피연산자가 rvalue 참조인 연산자도 필요하다. 클래스가 이동을 인지하면 갑자기 흥미로운 구현이 다양하게 가능해진다. 아래에서 그리고 다음 항에서 그런 사례를 만나보겠다. 먼저 그런 이항 연산자의 서명을 한 번 살펴보자 (역시 클래스 인터페이스에 친구로 선언되어 있어야 한다):
Binary operator+(Binary &&lhs, Binary const &rhs);
lhs 피연산자는 rvalue 참조이므로 마음대로 변경할 수 있다. 이항 연산자는 공장 함수로 설계되는 것이 보통이다. 공장 함수는 이 연산자가 생성한 객체를 돌려준다. C++ 표준은 명시적으로 부인하지는 않지만, 그렇게 변경된 왼쪽 피연산자를 rvalue 참조로 돌려주는 것은 당연히 규칙 위반이다. 이항 연산자로부터 돌려받은 객체는 그 연산자의 왼쪽 피연산자도 아니고 오른쪽 피연산자도 아니다. 대신에 이 연산자는 자신이 생성한 객체를 돌려주어야 한다. 그러나 변경된 왼쪽 피연산자의 사본을 이동 생성자를 사용하여 돌려주는 것은 문제가 없다.
C++ 표준에 의하면,
함수 호출에서 참조 매개변수에 묶인 임시 객체는 그 호출을 담고 있는 표현식이 완료할 때까지 생존해야 한다.게다가:
함수 반환 서술문에서 반환 값에 묶인 임시 객체의 생애는 지속되지 못한다. 이 임시 객체는 반환 서술문의 표현식이 끝나는 순간 소멸한다.다시 말해, 임시 객체 자체는 함수의 반환 값으로 돌려줄 수 없다.
Binary &&
반환 유형은 사용하면 안된다. 이항 연산자를 구현한 함수들은 그러므로 공장 함수이다 (그렇지만 임시 객체가 반환될 때마다 클래스의 이동 생성자를 사용하여 그 객체를 구성할 수 있다.).
대안으로 이항 연산자는 먼저 객체를 왼쪽 피연산자로부터 이동 생성해서 만들 수 있다. 다음에 직접적으로 그 객체와 rhs 피연산자에 이항 연산을 수행한다. 그 다음에 변경된 객체를 돌려줄 수 있다. 어느 것을 선호할지는 취향의 문제이다.
다음은 두 가지 구현이다. 복사 생략 때문에 명시적으로 정의된 ret
객체가 반환 값의 위치에 생성된다. 다르게 보이기는 하지만 두 구현 모두 실행 시간에 똑 같은 행위를 보여준다.
// 첫 번째 구현: lhs를 변경하는 방법 Binary operator+(Binary &&lhs, Binary const &rhs) { lhs.add(rhs); return std::move(lhs); } // 두 번째 구현: lhs로부터 ret를 이동 생성하는 방법 Binary operator+(Binary &&lhs, Binary const &rhs) { Binary ret{std::move(lhs)}; ret.add(rhs); return ret; }이제
b1 + b2 + b3
(모두 Binary
객체)와 같은 표현식을 실행하면 다음 함수들이 호출된다.
copy operator+ = b1 + b2 Copy constructor = tmp(b1) adding = tmp.add(b2) copy elision : b1 + b2으로부터 tmp가 반환된다. move operator+ = tmp + b3 adding = tmp.add(b3) Move construction = tmp2(move(tmp))가 반환된다.
그러나 아직 끝이 아니다. 다음 항에서 더 흥미로운 구현을 만나본다. 반영 할당 연산자에 집중하자.
operator+
와 같은) 이항 연산자를 아주 효율적으로 구현할 수 있음을 보았다. 그러나 여전히 이동 생성자가 필요하다.
그러므로 다음과 같은 표현식은
Binary{} + varB + varC + varD이동 생성되어
Binary{} + varB
으로 표현되는 객체를 돌려준다. 그 다음에 첫 번째 반환 값과 varC
를 받아 이동 생성된 또다른 객체를 돌려준다. 마지막으로 역시 두 번째 반환된 객체와 varD
를 인자로 받아 이동 생성된 또다른 객체를 돌려준다.
이제 첫 번째 매개변수로 Binary &&
를 두 번째 매개변수로 Binary const &
를 정의하고 있는 함수가 있다고 생각해 보자. 그 함수 안에서 이 값들은 더해질 필요가 있고, 총 합이 인자로 다른 두 함수에 건네진다. 다음과 같이 할 수 있다:
void fun1(Binary &&lhs, Binary const &rhs) { lhs += rhs; fun2(lhs); fun3(lhs); }그러나
operator+=
를 사용할 때 먼저 현재 객체의 사본을 생성한다. 그래서 임시 객체로 덧셈을 수행한 다음, 그 임시 객체를 현재 객체와 교환하여 결과를 산출한다. 그러나 잠깐! lhs 피연산자는 이미 임시 객체이다. 그런데 왜 또다른 임시 객체를 만들어야 할까?
이 예제에서는 임시 객체를 따로 더 만들 필요가 없다. 그러나 평범한 이항 연산자와 다르게 반영 할당 연산자는 왼쪽 피연산자가 명시적으로 정의되어 있지 않다. 그렇지만 이와 같은 상황에 그 멤버를 호출한 객체가 변경가능하거나 변경 불가능한 객체에 대한 rvalue 참조이거나 아니면 lvalue 참조일 때 컴파일러에게 (반영 할당 연산자 뿐만 아니라) 선택해야 할 멤버를 알려줄 수 있다. 이를 위해 참조 수식자(reference qualifiers)라고도 부르는 참조 묶기(reference bindings)를 사용한다.
참조 묶기는 참조 토큰(&
)으로 구성된다. 선택적으로 앞에 const
가 붙기도 한다. 아니면 rvalue 참조 토큰(&&
)으로 구성된다. 그런 참조 수식자는 즉시 함수 머리부에 붙는다 (선언과 구현에도 똑같이 적용된다). 익명 임시 객체가 사용되면 컴파일러는 rvalue 참조 묶기가 있는 함수를 선택한다. 반면에 다른 유형의 객체가 사용되면 컴파일러는 lvalue 참조 묶기가 있는 함수를 선택한다.
참조 수식자로 operator+=
를 세밀하게 구현할 수 있다. 반영 할당 연산자를 호출하는 객체가 임시 객체라는 사실을 알고 있다면 따로 임시 객체를 만들 필요가 없다. 반영 할당 연산자는 직접적으로 연산을 수행하고 반환하면 된다. 다음은 operator+=
를 임시 객체가 사용하도록 맞춤 재단한 구현이다.
Binary &&Binary::operator+=(Binary const &rhs) && { add(rhs); // 직접적으로rhs를 *this에 더하고, return std::move(*this); // 임시 객체 자체를 반환한다. }이 구현은 최대한의 속도로 빠르다. 그러자 주의하자. 이전 절에서 임시 객체는 반환 서술문이 끝나면 바로 소멸된다는 것을 배웠다. 그렇지만 이 경우 이미 임시 객체가 존재하고, 그래서 (
operator++
) 함수 호출을 담고 있는 표현식이 완료될 때까지 생존할 것이다. 결과적으로,
cout << (Binary{} += existingBinary) << '\n';위 서술문은 OK이지만
Binary &&rref = (Binary{} += existingBinary); cout << rref << '\n';위 서술문은 그렇지 않다.
rref
가 초기화되자마자 곧바로 허상 참조가 되어 버리기 때문이다.
operator+=
에 묶인 rvalue-참조를 확실하게 구현하는 다른 방법은 이동 생성된 사본을 돌려주는 것이다:
Binary Binary::operator+=(Binary const &rhs) && { add(rhs); // 직접적으로 rhs를 *this에 더하고, return std::move(*this); // 이동 생성된 사본을 돌려준다. }이 구현에 치루어야 할 대가는 따로 더 이동 생성이 필요하다는 것이다. 이전의 (
rref
) 예제를 사용하면, operator+=
는 Binary{}
임시 객체의 사본을 돌려주는데, 이 역시 rref
로 안전하게 참조할 수 있다.
어느 구현이든 선택의 문제이다. 무엇을 하고 있는지 잘 알고 있다면 앞의 구현을 사용해도 된다. 위의 rref
초기화를 사용하지 않을 것이기 때문이다. 잘 모르겠다면 뒤의 구현이 좋다. 해서는 안될 일을 하게 되더라도, 큰 희생이 따르지 않기 때문이다.
lvalue 참조로 (즉, 이름있는 객체로) 호출되는 반영 할당 연산자에 이전 절의 operator+=
에 사용하던 구현을 사용한다 (참조 수식자에 주목):
Binary &Binary::operator+=(Binary const &other) & { Binary tmp(*this); tmp.add(other); // 여기에서 예외를 던질 가능성이 있다. swap(tmp); return *this; }이 구현을 가지고 (
b1 += b2 += b3
와 같이) Binary
객체를 서로 더하면 다음과 같이 요약된다.
operator+= (&) = b2 += b3 Copy constructor = tmp(b2) adding = tmp.add(b3) swap = b2 <-> tmp return = b2 operator+= (&) = b1 += b2 Copy constructor = tmp(b1) adding = tmp.add(b2) swap = b1 <-> tmp return = b1
가장 왼쪽의 객체가 임시 객체이면 복사 생성과 교환 호출은 익명 객체의 생성으로 교체된다. Binary{} += b2 += b3
를 실행하면 다음과 같이 관찰할 수 있다.
operator+= (&) = b2 += b3 Copy constructor = tmp(b2) adding = tmp.add(b3) swap = b2 <-> tmp Anonymous object = Binary{} operator+= (&&) = Binary{} += b2 adding = add(b2) return = Binary{}
Binary &Binary::operator+=(Binary const &other) &
에 대하여 또다른 구현이 존재한다. return 서술문을 하나 사용한다. 그러나 실제로는 두 번의 함수 호출이 더 필요하다. 코드를 적게 쓰는게 좋을지 아니면 함수를 조금 호출하는게 좋을지 그것은 개인의 취향 문제이다.
Binary &Binary::operator+=(Binary const &other) & { return *this = Binary{*this} += rhs; }
operator+
와 operator+=
의 구현은 Binary
클래스의 실제 정의에 달려 있다. 그러므로 표준 이항 연산자를 클래스에 추가하는 것은 (즉, 자신만의 클래스 유형이 있는 인자에 작동하는 연산자는) 손쉽게 실현할 수 있다.
operator new
를 중복정의할 때 반드시 반환 유형을 void *
로 정의해야 하고 첫 매개변수는 유형이 size_t
이어야 한다. 기본 operator new
는 매개변수를 하나만 정의하고 있지만 중복정의 버전은 여러 개의 매개변수를 정의할 수도 있다. 첫 매개변수는 명시적으로 지정하지 않더라도 operator new
가 중복정의 클래스 객체의 크기로부터 추론해 낸다. 이 절은 operator new
를 중복정의하는 법을 연구한다. new[]
를 중복정의하는 것은 11.9절에 다루었다.
여러 버전의 operator new
를 정의할 수 있다. 각 버전마다 자신만의 독특한 인자 집합을 정의하고 있기만 하면 된다. 중복정의 operator new
멤버가 동적으로 메모리를 배당하려면 영역 지정 연산자 ::
를 적용해서 전역 operator new
를 사용하면 된다. 다음 예제에서 String
클래스의 중복정의 operator new
는 동적으로 할당된 String
객체의 실체들을 0-바이트로 초기화한다.
#include <cstring> #include <iosfwd> class String { std::string *d_data; public: void *operator new(size_t size) { return memset(::operator new(size), 0, size); } bool empty() const { return d_data == 0; } };위의
operator new
는 다음 프로그램에서 사용되는데, String
의 기본 생성자가 아무 일도 하지 않음에도 불구하고 객체의 데이타가 0으로 초기화되는 것을 보여준다.
#include "string.h" #include <iostream> using namespace std; int main() { String *sp = new String; cout << boolalpha << sp->empty() << '\n'; // 출력: true }
new String
에 다음과 같은 일이 일어났다.
String::operator new
가 호출되어 String
객체 크기만큼의 메모리 블록을 배당하고 초기화했다.
String
생성자에 건넸다. 생성자가 정의되어 있지 않기 때문에 생성자 자체는 아무 일도 하지 않았다.
String::operator new
가 0 바이트로 초기화했으므로 할당된 String
실체의 d_data
멤버는 존재할 즈음 이미 0-포인터로 초기화되어 있다.
지금까지 보아 온 멤버 함수는 (생성자와 소멸자를 포함하여) 모두 처리해야 할 객체를 가리키는 (숨은) 포인터가 정의되어 있다. 이 숨은 포인터는 함수의 this
포인터가 된다.
다음 C++ 코드는 포인터를 명시적으로 보여준다. operator new
가 사용될 때 무슨 일이 일어나는지 보여준다. String
객체의 앞부분에 str
이 직접적으로 정의되고 예제의 뒷부분에 (중복정의) operator new
가 사용된다.
String::String(String *const this); // 기본 생성자의 // 진짜 원형 String *sp = new String; // 이 서술문은 다음과 같이 // 구현된다. String *sp = static_cast<String *>( // 배당 String::operator new(sizeof(String)) ); String::String(sp); // 초기화위 코드에서 멤버 함수는
String
클래스의 실체 없는 멤버 함수로 취급되었다. 그런 멤버 함수를 정적 멤버 함수라고 부른다 (제 8장). 실제로 operator new
가 바로 그런 정적 멤버 함수이다. this
포인터가 없기 때문에 메모리에 있는 객체의 데이터 멤버에 도달할 수 없다. 오로지 메모리를 배당하고 초기화할 수 있을 뿐 실체의 데이터 멤버에 이름으로 접근할 수 없다. 아직 데이터 객체 레이아웃이 정의되어 있지 않기 때문이다.
배당 후에 그 메모리는 더 처리하기 위해 (this
포인터로) 생성자에 건네어진다.
operator new
는 매개변수를 여럿 가질 수 있다. 첫 매개변수는 언제나 size_t
이며 묵시적으로 초기화된다. 나머지 중복정의 연산자들도 매개변수를 추가로 정의할 수 있다. 흥미로운 operator new
는 배치 new
연산자이다. 메모리 블록은 미리 배당되어 있고 그 메모리를 초기화하기 위해 클래스의 생성자 중 하나가 사용된다. 배치 new
를 중복정의하려면 operator new
는 매개변수가 두 개 필요하다. 이미 배당된 메모리를 가리키는 size_t
와 char *
가 필요하다. size_t
매개변수는 묵시적으로 초기화된다. 그러나 나머지 매개변수들은 operator new
에 건넨 인자들을 사용하여 명시적으로 초기화해야 한다. 그리하여 익숙한 구문 형태에 도달한다. 배치 new
연산자는 다음과 같은 형태로 사용된다.
char buffer[sizeof(String)]; // 미리 정의된 메모리 String *sp = new(buffer) String; // 배치 new 호출배치
new
연산자를 String
클래스에 선언하면 다음과 같이 보인다.
void *operator new(size_t size, char *memory);다음과 같이 구현할 수 있다 (또
String
의 메모리를 0-바이트로 초기화한다):
void *String::operator new(size_t size, char *memory) { return memset(memory, 0, size); }중복정의
operator new
를 다른 버전으로 정의할 수도 있다. 다음 예제는 중복정의 operator new
를 정의하고 사용하는 법을 보여준다. 이 연산자는 String
객체를 가리키는 포인터의 기존 배열에 객체의 주소를 저장한다 (배열이 충분히 크다고 간주한다):
// 사용: String *next(String **pointers, size_t *idx) { return new(pointers, (*idx)++) String; } // 구현: void *String::operator new(size_t size, String **pointers, size_t idx) { return pointers[idx] = ::operator new(size); }
delete
연산자도 중복정의할 수 있다. 실제로 operator new
를 중복정의했다면 operator delete
도 중복정의하는 것이 좋은 관례이다.
operator delete
는 void *
매개변수를 정의해야 한다. 두 번째 중복정의 버전은 size_t
유형으로 두 번째 매개변수를 정의하는데 operator new[]
의 중복정의와 관련된다 (11.9절).
중복정의 operator delete
멤버는 void
를 돌려주어야 한다.
클래스의 소멸자를 실행한 후 동적으로 할당된 객체가 삭제될 때 `손수-만든' operator delete
가 호출된다. 그래서 다음 서술문은
delete ptr;
ptr
은 String
클래스의 실체를 가리키는 포인터이다. 거기에 delete
연산자가 중복정의되어 있다. 다음 서술문을 간략화한 형태이다.
ptr->~String(); // 클래스의 소멸자를 호출한다. // ptr이 가리키는 메모리로 일을 한다. String::operator delete(ptr);중복정의
operator delete
는 ptr
이 가리키는 메모리로 무엇이든 원하는 대로 할 수 있다. 예를 들어 그냥 삭제할 수 있다. 그러는 게 더 좋다면 ::
영역 지정 연산자로 기본 delete
연산자를 호출할 수도 있다. 예를 들어:
void String::operator delete(void *ptr) { // 필요한 연산이라고 생각된다면: ::delete ptr; }위의 중복정의
operator delete
를 선언하려면 다음 줄을 클래스 인터페이스에 추가하라:
void operator delete(void *ptr);
operator new
처럼 operator delete
는 정적 멤버 함수이다 (제 8장).
operator new[]
와 operator delete[]
를 소개했다. operator new
와 operator delete
처럼 new[]
와 delete[]
도 중복정의할 수 있다.
operator new
와 operator delete
는 물론이고 new[]
와 delete[]
도 중복정의할 수 있기 때문에 적절한 연산자를 선택할 때 주의해야 한다. 다음의 제일 규칙을 언제나 적용해야 한다.
new
연산자로 메모리를 배당했다면delete
연산자로 해제해야 한다.new[]
연산자로 메모리를 배당했다면delete[]
연산자로 해제해야 한다.
기본값으로 이 연산자들은 다음과 같이 행위한다.
operator new
는 단일 객체나 원시 값을 할당한다. 객체라면 그 객체의 생성자가 호출된다.
operator delete
는 operator new
가 배당한 메모리를 반납한다. 역시, 클래스 유형의 객체라면 그 클래스의 소멸자가 호출된다.
operator new[]
는 일련의 원시 값 또는 실체를 할당한다. 일련의 실체를 할당하면 클래스의 기본 생성자가 호출되어 실체마다 따로따로 초기화된다.
operator delete[]
를 사용하여 이전에 new[]
로 배당된 메모리를 삭제한다. 만약 실체가 할당되어 있다면 소멸자가 실체마다 호출된다. 그렇지만 실체를 가리키는 포인터가 할당되어 있다면 그 포인터가 가리키는 실체의 소멸자는 자동으로 호출되지 않는다는 것을 주의하라. 포인터는 원시 유형이고 그래서 공용 풀로 반납될 때 아무 조치도 취하지 않는다.
String
클래스) operator new[]
를 중복정의하려면 다음 줄을 클래스 인터페이스에 추가하면 된다.
void *operator new[](size_t size);멤버의
size
매개변수는 묵시적으로 공급되고 C++의 실행시간 시스템이 배당해야 하는 메모리 양만큼 초기화한다. 간단한 실체-하나 짜리 operator new
와 마찬가지로 void *
를 돌려 주어야 한다. 초기화해야 하는 객체의 개수는 size / sizeof(String)
으로 쉽게 계산할 수 있다 (물론 다른 클래스에 operator new[]
를 중복정의할 때는 String
을 적절한 클래스 이름으로 교체해야 한다.). 중복정의 new[]
멤버는 날 메모리를 배당할 수 있다. 예를 들어 기본 operator new[]
또는 기본 operator new
를 사용한다.
void *operator new[](size_t size) { return ::operator new[](size); // 다른 방법: // return ::operator new(size); }배당된 메모리를 돌려주기 전에
operator new[]
중복정의 함수는 뭔가 특별한 일을 할 기회가 있다. 예를 들어 메모리를 0-바이트로 초기화할 수 있다.
중복정의 operator new[]
가 정의되면 다음과 같은 서술문에 자동으로 사용된다.
String *op = new String[12];
operator new
연산자처럼 operator new[]
연산자에도 추가로 중복정의할 수 있다. operator new[]
중복정의의 한 가지 가능성은 특별히 객체 배열을 위하여 배치 new
를 중복정의하는 것이다. 이 연산자는 기본으로 사용할 수 있지만 일단 operator new[]
가 하나라도 정의되면 사용이 불가능해진다. 배치 new
를 구현하는 것은 어렵지 않다. 다음은 사용가능한 메모리를 돌려주기 전에 0-바이트로 초기화하는 예이다.
void *String::operator new[](size_t size, char *memory) { return memset(memory, 0, size); }이 중복정의 연산자를 사용하려면 두 번째 매개변수도 다음과 같이 건네야 한다.
char buffer[12 * sizeof(String)]; String *sp = new(buffer) String[12];
String
클래스에서 operator delete[]
를 중복정의하려면 다음 줄을 클래스 인터페이스에 추가한다.
void operator delete[](void *memory);
String::new[]
가 이전에 배당한 메모리 블록의 주소로 매개변수가 초기화된다.
operator delete[]
연산자를 구현할 때 주의할 점이 몇 가지 있다. new
연산자와 new[]
연산자는 주소를 돌려준다. 할당된 객체를 이 주소가 가리키고 있지만 그 주소 바로 앞에 있는 size_t
값을 사용할 수 있다. 이 size_t
값은 배당된 블록에 포함되어 있으며 실제 블록의 크기가 담겨 있다. 물론 이것은 배치 new
연산자에는 적용되지 않는다.
클래스가 소멸자를 정의할 때 new[]
가 돌려주는 주소 앞의 size_t
값은 배당된 블록의 크기가 담겨 있지 않고 new[]
를 호출할 때 지정된 객체의 갯수가 담겨 있다. 보통은 관심의 대상이 아니지만 operator delete[]
을 중복정의할 때는 유용한 정보가 될 수 있다. 그런 경우 operator delete[]
는 new[]
가 돌려준 주소를 받지 않는다. 오히려 최초의 size_t
값의 주소를 받는다. 이것이 유용하지 아닌지는 명확하지 않다. delete[]
의 코드가 실행되면 모든 객체가 이미 파괴된 것이다. 그래서 operator delete[]
는 얼마나 많은 객체가 소멸했는지 결정하기만 하면 된다. 그러나 그 객체들은 이미 파괴되어 존재하지 않는다.
다음은 operator delete[]
의 이 행위를 보여주는 예이다. 최소한의 Demo
클래스이다.
struct Demo { size_t idx; Demo() { cout << "default cons\n"; } ~Demo() { cout << "destructor\n"; } void *operator new[](size_t size) { return ::operator new(size); } void operator delete[](void *vp) { cout << "delete[] for: " << vp << '\n'; ::operator delete[](vp); } }; int main() { Demo *xp; cout << ((int *)(xp = new Demo[3]))[-1] << '\n'; cout << xp << '\n'; cout << "==================\n"; delete[] xp; } // 이 프로그램을 다음과 같이 보여준다 (컴퓨터마다 0x?????? 주소는 다르다. // 그러나 둘 사이의 차이는 sizeof(size_t)이다): // default cons // default cons // default cons // 3 // 0x8bdd00c // ================== // destructor // destructor // destructor // delete[] for: 0x8bdd008
String
클래스에 대하여 중복정의 operator delete[]
가 있으므로 다음과 같은 서술문에 자동으로 사용된다.
delete[] new String[5];
delete[]
연산자는 size_t
매개변수를 사용해 중복정의할 수도 있다.
void operator delete[](void *p, size_t size);여기에서
size
는 void *p
가 가리키는 메모리 블록의 크기로 (바이트 단위로) 자동으로 초기화된다. 이런 형태로 정의되어 있으면 void operator[](void *)
형태는 정의하면 안된다. 모호성을 피하기 위해서이다. operator delete[]
형태의 예는 다음과 같다.
void String::operator delete[](void *p, size_t size) { cout << "deleting " << size << " bytes\n"; ::operator delete[](ptr); }
따로 더 operator delete[]
를 중복정의할 수도 있지만 그것을 사용하려면 정적 멤버 함수로 명시적으로 호출해야 한다 (제 8장). 예제:
// 선언: void String::operator delete[](void *p, ostream &out); // 사용법: String *xp = new String[3]; String::operator delete[](xp, cout);
operator delete
와 operator delete[]
멤버를 중복정의할 수 있다.
C++14 표준은 또 전역적 void operator delete(void *, size_t size)
함수와 void operator delete[](void *, size_t size)
함수를 중복정의하는 것도 지원한다.
크기가 없는 기본의 반납 함수 대신에 크기가 있는 전역적 반납 함수를 정의하면 그것이 자동으로 사용된다. 그러면 프로그램의 수행성능을 향상시킬 수 있다 (참조 http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2013/n3663.html).
new[]
표현식을 실행하는 동안 예외가 던져지면 무슨 일이 일어날까? new[]
가 예외에 안전하다는 사실을 이번 항에 보여주겠다. 객체들이 제대로 생성되지 않았을 경우에도 안전하다.
먼저, new[]
는 필요한 메모리 배당을 시도하는 중에 예외를 던질 수도 있다. 이 경우 bad_alloc
이 던져진다. 아무것도 할당되지 않았으므로 메모리 누수는 없다.
필요한 메모리를 배당하고 나면 순서대로 실체마다 클래스의 기본 생성자가 사용된다. 어느 시점에 생성자는 예외를 던질 수도 있다. 다음으로 일어나는 일은 C++ 표준에 정의되어 있다. 이미 생성된 객체의 소멸자가 호출되고 객체 자체에 배당된 메모리가 공용 풀에 반납된다. 그러므로 생성자가 실패하더라도 기본 보장을 제공한다. 생성자가 예외를 던지더라도 new[]
는 예외에 안전하다.
다음 예제는 이 행위를 보여준다. 다섯 개의 객체를 할당하고 초기화해 달라고 요청한다. 그러나 객체를 두 개 생성한 후에 더 이상 생성하지 못하고 예외를 던진다. 출력을 보면 생성된 객체의 소멸자가 적절하게 호출되고 배당된 실제 메모리도 적절하게 반납되고 있음을 볼 수 있다.
#include <iostream> using namespace std; static size_t count = 0; class X { int x; public: X() { if (count == 2) throw 1; cout << "Object " << ++count << '\n'; } ~X() { cout << "Destroyed " << this << "\n"; } void *operator new[](size_t size) { cout << "Allocating objects: " << size << " bytes\n"; return ::operator new(size); } void operator delete[](void *mem) { cout << "Deleting memory at " << mem << ", containing: " << *static_cast<int *>(mem) << "\n"; ::operator delete(mem); } }; int main() try { X *xp = new X[5]; cout << "Memory at " << xp << '\n'; delete[] xp; } catch (...) { cout << "Caught exception.\n"; } // 이 프로그램의 출력 (0x??? 주소는 컴퓨터마다 다르다.) // Allocating objects: 24 bytes // Object 1 // Object 2 // Destroyed 0x8428010 // Destroyed 0x842800c // Deleting memory at 0x8428008, containing: 5 // Caught exception.
operator()
를 재정의해 만든다. 그러면 객체가 함수로 변신한다. 그래서 용어가 함수객체이다. 함수객체는 펑터(functors)라고도 부른다.
함수객체는 총칭 알고리즘을 사용할 때 중요하다. 함수를 가리키는 포인터 같은 대안들보다 함수객체를 사용하는 것이 더 좋다. 함수객체가 총칭 알고리즘의 문맥에서 중요하다는 사실 때문에 강의 순서에 고민이 좀 있었다. 지금쯤이면 총칭 알고리즘을 이미 다루었다면 좋았을 테지만 총칭 알고리즘을 논의하려면 함수객체에 대한 지식이 필요하다. 닭이 먼저냐 달걀이 먼저냐 하는 이 문제는 유명한 방법으로 해결한다. 당분간 의존성을 무시한다. 지금 당장은 함수객체의 개념에 집중하겠다.
함수객체는 operator()
가 정의된 객체이다. 함수객체는 총칭 알고리즘과 조합하여 사용될 뿐만 아니라 함수를 가리키는 포인터의 (보다 더 좋은) 대안으로도 사용된다.
함수객체는 진위함수를 구현하는 데 자주 사용된다. 진위 함수는 불리언 값을 돌려준다. 진위 함수와 진위 함수객체는 `진위술어(predicates)'라고 부른다. 진위술어는 총칭 알고리즘에 자주 사용된다. 예를 들어 count_if 총칭 알고리즘이 있다. 자신의 함수객체가 true
를 반환하는 횟수를 돌려준다 (제 19장). 표준 템플릿 라이브러리에 두 종류의 진위술어가 사용된다. 단항 진위술어는 인자를 한 개 받고, 이항 진위술어는 인자를 두 개 받는다.
Person
클래스와 그 실체의 배열이 있다고 가정하자. 또 그 배열은 정렬되어 있지 않다고 가정하자. 배열에서 특정한 Person
실체를 찾으려면 lsearch
함수를 사용한다. 이 함수는 배열을 선형적으로 검색한다. 예를 들어:
Person &target = targetPerson(); // 찾을 사람을 결정한다. Person *pArray; size_t n = fillPerson(&pArray); cout << "The target person is"; if (!lsearch(&target, pArray, &n, sizeof(Person), compareFunction)) cout << " not"; cout << "found\n";
targetPerson
함수는 찾을 사람을 결정하고 fillPerson
함수는 배열을 채우기 위해 호출된다. 다음 lsearch
함수로 목표를 찾는다.
lsearch
함수의 인자 중 하나에 비교 함수의 주소가 필요하다. 주소가 있는 실제 함수이어야 한다. 인라인으로 정의되었다면 컴파일러는 그 요청을 무시하는 수 밖에 다른 선택이 없다. 인라인 함수는 주소가 없기 때문이다. CompareFunction
함수는 다음과 같이 구현할 수 있다.
int compareFunction(void const *p1, void const *p2) { return *static_cast<Person const *>(p1) // lsearch에서 객체가 같으려면 != // 0이 필요하다. *static_cast<Person const *>(p2); }물론 이것은
Person
클래스에 operator!=
가 중복정의되어 있다고 간주한다. 그러나 operator!=
를 중복정의하는 것은 큰 문제가 아니다. 그래서 그 연산자를 실제로 사용할 수 있다고 간주하자.
적어도 평균 n / 2
회로 다음 조치가 일어난다.
lsearch
함수의 최종 매개변수의 값을 결정해 compareFunction
의 주소를 생산한다.
Person::operator!=
인자의 오른쪽 인자의 주소를 스택에 넣는다.
Person::operator!=
를 평가한다.
Person::operator!=
함수를 스택에서 꺼낸다.
PersonSearch
함수를 생성했다고 간주하자. 원형은 다음과 같다 (바람직한 접근법은 아니다. 당연히 손수 만든 함수보다 총칭 알고리즘이 더 좋다. 그러나 지금은 PersonSearch
에 함수객체의 구현과 그 사용법을 보여주는데 초점이 있다):
Person const *PersonSearch(Person *base, size_t nmemb, Person const &target);이 함수는 다음과 같이 사용할 수 있다.
Person &target = targetPerson(); Person *pArray; size_t n = fillPerson(&pArray); cout << "The target person is"; if (!PersonSearch(pArray, n, target)) cout << " not"; cout << "found\n";지금까지는 별로 많이 바뀌지 않았다.
lsearch
호출을 또다른 함수 호출로 바꾸었을 뿐이다. PersonSearch
함수로 바꾸었다. 이제 PersonSearch
함수 자체를 살펴 보자:
Person const *PersonSearch(Person *base, size_t nmemb, Person const &target) { for (int idx = 0; idx < nmemb; ++idx) if (target(base[idx])) return base + idx; return 0; }
PersonSearch
함수는 평범한 선형 검색을 구현한다. 그렇지만 for-회돌이에 target(base[idx])
이 있다. 여기에 target
이 함수객체로 사용된다. 그의 구현은 간단하다.
bool Person::operator()(Person const &other) const { return *this == other; }약간 특이한 구문인
operator()
에 주목하라. 앞의 괄호는 중복정의 연산자를 정의한다. 함수 호출 연산자를 중복정의했다. 뒤의 괄호는 이 중복정의 연산에 필요한 매개변수들을 정의한다. 이 중복정의 연산자는 클래스의 머리 파일에 다음과 같이 선언된다.
bool operator()(Person const &other) const;분명히
Person::operator()
는 단순한 함수이다. 안에 서술문이 겨우 하나 있을 뿐이다. 인라인으로 정의하는 것을 고려해 볼 수 있다. 그렇게 했다고 가정하면 operator()
가 호출될 때 다음과 같은 일이 일어난다.
Person::operator==
의 오른쪽 인자의 주소를 스택에 넣는다.
operator==
함수를 평가한다 (이것도 역시 지정된 목표 객체와 같은 객체를 찾을 때 operator!=
를 호출하는 것보다 의미구조적으로 개선된 것이다.).
Person::operator==
의 인자를 스택에서 꺼낸다.
operator()
는 인라인 함수이기 때문에 실제로는 호출되지 않는다. 대신에 즉시 operator==
가 호출된다. 게다가 필요한 스택 연산은 설명이 필요없을 정도로 단순하다.
함수객체는 진짜 인라인으로 정의할 수 있다. 간접적으로 호출되는 (즉, 함수를 가리키는 포인터를 사용한) 함수는 절대로 인라인으로 정의할 수 없다. 주소를 알 수 없기 때문이다. 간접 호출의 유연성이라는 장점이 수행 부담 때문에 훼손될 수 있다. 이 경우 인라인 함수객체를 사용하면 프로그램의 효율성을 높일 수 있다.
함수객체의 장점은 여기에 그치지 않는다. 함수객체는 객체의 비공개 데이터에 접근할 수 있다. (lsearch
함수처럼) 비교 함수가 사용되는 검색 알고리즘에서 처리 대상과 배열 원소는 포인터를 사용하여 비교 함수에 건네지는데, 스택 처리가 덧붙어서 관련된다. 함수객체를 사용하면 목표로 한 사람은 단일 검색 작업 안에서 바뀌지 않는다. 그러므로 목표로 한 사람을 함수객체의 클래스 생성자에 건넬 수 있다. 실제로 이것이 표현식 target(base[idx])
안에 일어나는 일이다. 배열의 원소들을 잇따라 하나씩 인자로 받아 검색한다.
cout
<< hex
<< 13
<<와 같은 생성자들을 보았다. 어떤 마법으로 hex
조작자가 이런 일을 완수하는지 궁금할 것이다. 이 항은 hex
와 같은 조작자를 만드는 방법을 다룬다.
실제로 손쉽게 조작자를 만들 수 있다. 먼저 조작자의 정의가 필요하다. 조작자 w10
을 만들고 싶다고 해 보자. ostream
객체가 쓸 다음 필드의 너비를 10으로 설정하고 싶다. 이 조작자는 함수로 생성된다. w10
함수는 너비를 설정해야 할 ostream
객체에 관하여 알 필요가 있다. 함수에 ostream &
매개변수를 건네어 이 사실을 알린다. 이제 함수는 ostream
객체에 너비를 설정할 수 있다.
다음, 조작자를 연속적인 삽입에 사용할 수 있어야 한다. 이것은 조작자가 ostream
객체를 참조로 돌려주어야 한다는 뜻이다.
위의 연구로부터 이제 w10
함수를 생성할 수 있다.
#include <ostream> #include <iomanip> std::ostream &w10(std::ostream &str) { return str << std::setw(10); }
물론 w10
함수는 `단독' 모드로 사용할 수도 있고 또한 조작자로도 사용할 수 있다. 예를 들어,
#include <iostream> #include <iomanip> using namespace std; extern ostream &w10(ostream &str); int main() { w10(cout) << 3 << " ships sailed to America\n"; cout << "And " << w10 << 3 << " more ships sailed too.\n"; }
w10
함수를 조작자로 사용할 수 있다. class ostream
에 중복정의 operator<<
가 있어서 ostream &
를 기대하고 ostream &
을 돌려주는 함수를 포인터로기 때문이다. 그의 정의는 다음과 같다.
ostream& operator<<(ostream &(*func)(ostream &str)) { return (*func)(*this); }위의 중복정의
operator<<
말고도 또다른 버전도 정의된다.
ios_base &operator<<(ios_base &(*func)(ios_base &base)) { (*func)(*this); return *this; }
hex
또는 internal
을 삽입할 때 이 함수가 사용된다.
위의 절차는 인자를 요구하는 조작자에는 작동하지 않는다. 물론 operator<<
를 중복정의하여 ostream
참조를 받을 수 있다. ostream &
과 int
를 기대하는 함수의 주소를 받을 수 있다. 그러나 그런 함수의 주소를 << 연산자로 지정할 수는 있지만 인자 자체는 지정할 수 없다. 그래서 어떻게 다음 생성을 구현했는지 궁금할 것이다.
cout << setprecision(3)이 경우 조작자는 매크로로 정의된다. 그렇지만 매크로 조작자는 전처리기의 영역이다. 어렵지 않게 불쾌한 부작용을 경험할 수 있다. C++ 프로그램에서는 매크로를 피하는 것이 좋다.
operator<<
연산자가 사용되면 컴파일러는 함수들을 호출하여 반환 값을 절약한다. 그리고 반환 값을 연이어 삽입에 사용한다. 그 때문에 << 연산자에 건넨 인자들의 순서는 무효가 된다.
그래서 함수의 주소를 받는 또다른 중복정의 operator<<
버전을 생성하는 방법을 고려할 수 있다. 이 함수는 ostream
참조를 받을 뿐만 아니라 일련의 다른 인자도 받는다. 그러나 이렇게 하면 어떻게 인자들을 함수가 받는지 분명하지 않다는 문제가 생긴다. 단순히 그것을 호출할 수는 없다. 그렇게 하면 다시 위에 언급한 문제로 되돌아가기 때문이다. 그냥 그의 주소만 건네는 것도 가능하지만 그러면 함수에 인자를 전혀 건넬 수 없다.
조작자는 인자를 요구할 수도 있지만 매크로를 사용하지 않고서도 정의할 수 있다. 익명 객체에 기반한 해결책이 존재한다. (cin
이나 cout
처럼) 전역적으로 사용 가능한 객체들을 변경하는 데 알맞다.
Align
의 생성자는 여러 인자를 기대한다. 예제에서 각각 필드 너비와 정렬 방식을 나타낸다.
ostream &operator<<(ostream &ostr, Align const &align)
Align::align
에 건넨다. 그러면 이 멤버는 제공된 스트림을 구성해 돌려줄 수 있다. 그래서 Align
객체를 스트림에 삽입할 수 있다.
#include <iostream> #include <iomanip> class Align { unsigned d_width; std::ios::fmtflags d_alignment; public: Align(unsigned width, std::ios::fmtflags alignment); std::ostream &operator()(std::ostream &ostr) const; }; Align::Align(unsigned width, std::ios::fmtflags alignment) : d_width(width), d_alignment(alignment) {} std::ostream &Align::operator()(std::ostream &ostr) const { ostr.setf(d_alignment, std::ios::adjustfield); return ostr << std::setw(d_width); } std::ostream &operator<<(std::ostream &ostr, Align const &&align) { return align(ostr); } using namespace std; int main() { cout << "`" << Align(5, ios::left) << "hi" << "'" << "`" << Align(10, ios::right) << "there" << "'\n"; } /* 출력: `hi '` there' */
익명의 Align
객체를 ostream
에 삽입하려면 operator<<
함수는 Align const &
매개변수를 정의해야 한다 (const
수식자를 눈여겨보라).
(지역) 객체를 조작해야 한다면 조작자를 제공해야 하는 클래스는 함수 호출 연산자를 정의해 요구된 인자를 받을 수 있다. 예를 들어 Matrix
클래스를 연구해 보자. 사용자는 행렬을 ostream
에 삽입할 때 값과 줄 가름자를 지정할 수 있다.
두 개의 데이터 멤버가 정의되고 초기화된다 (char const *d_valueSep
그리고 char const *d_lineSep
). 삽입 함수는 d_valueSep
를 값 사이에 그리고 그 줄 끝에 d_lineSep
를 삽입한다. operator()(char const *valueSep, char const *lineSep)
멤버는 값들을 상응하는 데이터 멤버에 그냥 할당한다.
Matrix matrix
라는 실체가 있다면 이 시점에서 matrix(" ", "\n")
를 호출할 수 있다. 함수 호출 연산자는 행렬을 삽입하면 안된다. 조작자의 임무는 삽입이 아니라 조작이기 때문이다. 그래서 행렬을 삽입하기 위해 다음과 같은 서술문을 사용할 수 있을 것 같다.
cout << matrix(" ", "\n") << matrix << '\n';조작자는 (즉, 함수 호출 연산자는) 적절한 값을
d_valueSep
과 d_lineSep
에 할당한다. 실제로 삽입하는 동안에 사용된다.
함수 호출 연산자의 반환 값을 여전히 지정할 필요가 있다. 반환 값을 삽입할 수는 있겠지만, 사실 절대로 삽입하면 안 된다. 빈 NTBS가 반환될 수 있다. 그러나 부질없는 걱정이다. 대신에 아무 일도 하지 않는 조작 함수의 주소를 돌려줄 수 있다. 다음은 그런 빈 조작자의 구현이다:
// static (다른 방법으로서 자유 함수를 사용할 수 있다) std::ostream &Matrix::nop(std::ostream &out) { return out; }그리하여,
Matrix
의 조작자 구현은 모습이 다음과 같이 된다.
std::ostream &( *Matrix::operator()(char const *valueSep, char const *lineSep) ) (std::ostream &) { d_valueSep = valueSep; d_lineSep = lineSep; return nop; }
[io]fstream::open
멤버는 ios::openmode
값을 마지막 인자로 기대한다고 지적하였다. 예를 들어 쓰기를 위해 fstream
객체를 열려면 다음과 같이 할 수 있다.
fstream out; out.open("/tmp/out", ios::out);조합해서 사용하는 것도 가능하다. 읽기와 쓰기로
fstream
객체를 열기 위해 다음 코드가 사용되는 것을 자주 볼 수 있다.
fstream out; out.open("/tmp/out", ios::in | ios::out);
`손수 만든' enum
을 사용하여 열거 값을 조합하려고 시도할 때 문제에 봉착할 수 있다. 다음을 연구해 보자:
enum Permission { READ = 1 << 0, WRITE = 1 << 1, EXECUTE = 1 << 2 }; void setPermission(Permission permission); int main() { setPermission(READ | WRITE); }이 작은 프로그램을 컴파일러에게 주면 다음과 같이 에러 메시지로 응답한다.
invalid conversion from 'int' to 'Permission'
'int'로부터 'Permission'으로 불법 변환
ios::openmode
값을 조합한 값들을 스트림의 open
멤버에 건네면 문제가 없는데, 왜 Permission
값을 조합해서 건네면 문제가 되는가?
산술 연산자를 사용하여 열거 값을 조합하면 그 결과 값의 유형은 int
가 된다. 개념적으로 이것은 의도한 바가 아니다. 열거 값들을 조합한 결과 값은 여전히 원래의 열거 영역 안에 의미가 있어야 개념적으로 올바르다고 간주할 수 있다. READWRITE = READ | WRITE
값을 위의 enum
에 추가한 후에도 여전히 READ | WRITE
값을 setPermission
에 인자로 지정할 수 없음을 주의하라.
열거체 값을 조합하는 것에 관한 질문에 답하면서도 여전히 그 열거체의 영역 안에 있기 위해 연산자 중복정의에 의존한다. 이 시점까지 연산자 중복정의는 클래스 유형에 적용해 왔다. operator<<
같은 자유 함수를 중복정의했고 그런 중복정의 함수는 개념적으로 자신의 클래스의 영역 안에 존재한다.
C++ 언어는 유형이 강력하게 정의되는 언어이기 때문에 열거체(enum
)를 정의하는 것은 단순히 int
값을 심볼 이름에 연관짓는 일을 넘어서는 일이다. 열거 유형은 그 자체로 하나의 유형이기 때문이다. 그리고 다른 어떤 유형과 마찬가지로 열거체의 연산자도 중복정의할 수 있다. READ | WRITE
로 쓰면 컴파일러는 열거 값을 int
값으로 변환한다. 그리고 다른 대안이 없을 때 그 연산자를 int
에 적용한다.
그러나 열거 유형의 연산자를 중복정의하는 것도 가능하다. 그러면 결과 값이 열거체에 정의되어 있지 않아도 여전히 열거체의 영역에 있음을 확신할 수 있다. 약간 특이하게도 열거체에 정의되어 있지 않은 값들을 새로 도입하는 것보다 유형-안전과 개념적 명확성이 주는 장점이 더 중요하다고 여겨진다.
다음은 그런 중복정의 연산자의 예이다.
Permission operator|(Permission left, Permission right) { return static_cast<Permission>(static_cast<int>(left) | right); }비슷하게 다른 연산자들도 쉽게 생성할 수 있다.
위와 같은 연산자들은 ios::openmode
열거 유형에 대하여 정의된다. 덕분에 상응하는 매개변수에 ios::openmode
를 지정하면서도 ios::in | ios::out
를 인자로 open
에 지정할 수 있다. 연산자 중복정의는 많은 상황에 사용할 수 있다. 꼭 클래스 유형에만 연관지을 필요가 없다.
사용자-정의 기호상수는 함수로 정의된다 (23.3절). 이 함수는 반드시 이름공간 영역에 정의되어야 한다. 그런 함수를 기호상수 연산자라고 부른다. 기호상수 연산자는 클래스의 멤버 함수가 될 수 없다. 기호상수 연산자의 이름은 반드시 밑줄 문자로 시작해야 하며, 기호상수 연산자는 건네야 하는 인자의 뒤에 (밑줄 문자를 포함하여) 이름을 덧붙여 사용된다 (호출된다). 만약 _NM2km
(nautical mile to km - 해리(바닷길의 거리 단위. 1.852km))이 기호상수 연산자의 이름이라고 간주하면 100_NM2km
와 같이 호출할 수 있고 그 결과 185.2를 돌려준다.
Type
을 사용하여 기호상수 연산자의 반환 유형을 나타내려면 그의 총칭 선언은 다음과 같다.
Type operator "" _identifier(parameter-list);빈 문자열 다음에 빈 공간은 꼭 띄어야 한다. 기호상수 연산자의 매개변수 리스트는 다음이 될 수 있다.
unsigned long long int
: 123_identifier
처럼 사용된다. 이 기호상수 연산자에 건네는 인자는 십진 상수나 (0b으로 시작하는) 이진 상수 또는 (0으로 시작하는) 팔진 상수나 (0x으로 시작하는) 십육진 상수가 될 수 있다.
long double
: 12.25_NM2km
처럼 사용된다.
char const *text
: text
인자는 NTBS이다. 1234_pental
처럼 사용된다. 인자에 겹따옴표를 붙이면 안 된다. 그리고 숫치 상수를 나타내야 한다. unsigned long long int
매개변수를 정의한 기호상수 연산자가 기대하는 바와 같다.
char const *text, size_t len
: strlen(text)
을 호출한 것처럼 len
을 결정한다. 용례: "hello"_nVowels
;
wchar_t const *text, size_t len
: wchar_t
문자열을 받는다. 용례: L"1234"_charSum
;
char16_t const *text, size_t len
: char16_t
문자열을 받는다. 용례: u"utf 16"_uc
;
char32_t const *text, size_t len
: char32_t
문자열을 받는다. 용례: U"UTF 32"_lc
;
unsigned long long int
매개변수를 정의하고 있는 기호상수 연산자가 처리한다. char const *
매개변수를 정의하고 있는 그의 중복정의 버전이 처리하는 것이 아니다. 그러나 char const *
와 long double
매개변수를 정의한 중복정의 기호상수 연산자가 존재할 때 인자로 120을 건네면 char const *
매개변수를 정의한 연산자가 사용되고 long double
매개변수를 정의한 연산자는 인자 120.3에 사용된다.
기호상수 연산자는 어떤 반환 유형도 정의할 수 있다. 다음은 _NM2km
기호상수 연산자를 정의하는 예이다.
double operator "" _NM2km(char const *nm) { return std::stod(nm) * 1.852; } double value = 120_NM2km; // 사용 방법물론 인자는
long double
상수일 수도 있다. 다음은 명시적으로 long double
을 기대하는 다른 구현이다.
double constexpr operator "" _NM2km(long double nm) { return nm * 1.852; } double value = 450.5_NM2km; // 사용 방법
숫치형 상수도 완벽하게 컴파일 시간에 처리할 수 있다. 23.3절은 이런 유형의 기호상수 연산자들을 상세하게 설명한다.
기호상수 연산자에 건네는 인자들은 자체로 언제나 상수이다. _NM2km
와 같은 기호상수 연산자들은 변수의 값을 변환할 수 없다. 함수로 정의되어 있음에도 불구하고 기호상수 연산자는 함수처럼 호출할 수 없다. 그러므로 다음 예제는 컴파일 에러가 일어난다.
double speed; speed_NM2km; // 식별자 없음 'speed_NM2km' _NM2km(speed); // 함수 없음 _NM2km _NM2km(120.3); // 함수 없음 _NM2km
+ - * / % ^ & | ~ ! , = < > <= >= ++ -- << >> == != && || += -= *= /= %= ^= &= |= <<= >>= [] () -> ->* new new[] delete delete[]어떤 연산자들은 텍스트 형태로 대안이 있다.
텍스트형 | 연산자 |
and | && |
and_eq | &= |
bitand | & |
bitor | | |
compl | ~ |
not | ! |
not_eq | != |
or | || |
or_eq | |= |
xor | ^ |
xor_eq | ^= |
operator and
같은) `텍스트형' 연산자도 중복정의할 수 있다. 그렇지만 텍스트형 연산자는 추가 연산자가 아님을 주의하라. 그래서 같은 문맥 안에서 operator&&
그리고 operator and
를 둘 다 중복정의할 수는 없다.
이 연산자 중에는 클래스 안에서 멤버 함수로만 중복정의가 가능한 것이 있다. 이 원칙은 '='
, '[]'
, '()'
그리고 '->'
연산에도 해당된다. 결과적으로 할당 연산자를 전역적으로 재정의하는 것은 불가능하다. char const *
를 lvalue로 받고 String &
을 rvalue로 받는 방식은 안된다. 다행스럽게도 11.3절에서 본 바와 같이 그것이 반드시 필수는 아니다.
마지막으로, 다음 연산자는 중복정의할 수 없다.
. .* :: ?: sizeof typeid