– 복사 생성자 (Copy Constructor)
– 위임 생성자 (Delegating Constructor)
– Default, Delete
– L-value, R-value
– Move
이미 만들어진 객체를 통해 객체를 초기화하는 생성자. 컴파일러에 의해 자동 생성되고, 단순히 값을 복사하는 형식으로 구현된다. 만약 클래스의 멤버 변수 중 포인터 멤버가 존재할 경우에는 단순히 그 주소가 복사될 뿐이기 때문에, 사용자 정의 복사 생성자가 필요한 경우가 있다.
#include <iostream>
class Integer {
public:
int *m_pInt;
Integer(int value) {
m_pInt = new int;
*m_pInt = value;
}
};
int main()
{
Integer i1(3);
Integer i2 = i1;
}
다음과 같은 경우 i2은 i1을 인자로 하는 복사 생성자에 의해 초기화된다. 이 때 포인터 멤버 변수인 m_pInt는 단순히 i1의 m_pInt가 가리키는 주소를 가져온 것이 된다. 따라서 두 객체는 동일한 주소를 m_pInt에 각각 저장하게 된다.
위 코드는 오류가 발생하지 않지만, 저 영역을 수정하는 함수를 구현하거나 소멸자에서 저 공간을 release 하게 한다면 즉시 에러가 발생한다. 두 객체에 대해 각각 소멸자가 호출되는데, 두 번째로 소멸자가 호출될 때는 이미 delete된 메모리를 또 delete 하려고 하기 때문이다.
Integer(Integer & obj) {
m_pInt = new int(*obj.m_pInt);
}
다음과 같이 사용자 정의 복사 생성자를 구현할 수 있다. m_pInt는 새로운 메모리를 할당한 후 인자로 들어온 obj 레퍼런스의 m_pInt의 값을 할당받은 공간에 저장한다. 두 객체의 m_pInt는 서로 다른 공간을 가리킨다.
이러한 이유로 소멸자 / 복사 생성자 / 복사 대입 연산자는 반드시 셋 다 구현하거나 모두 컴파일러가 자동 생성하게 두어야한다. 만약 그렇지 않을 경우 소멸자가 메모리를 해제하거나 복사 생성자가 복사를 수행하는 과정에서 shallow copy가 발생하거나 memory leak이 발생할 수 있다.
생성자에서 또 다른 생성자를 호출 할 수 있다.
class Student {
public:
int grade;
int classroom;
Student(): Student(0, 0) {
}
Student(int grade): Student(grade, 0) {
}
Student(int grade, int classroom) {
this->grade = grade;
this->classroom = classroom;
}
};
생성자를 호출하는 여러가지 상황에 따라 코드가 중복될 수 있는 소지가 있다. 위와 같이 기본 생성자나 매개변수가 1개인 생성자에서 매개변수가 2개인 생성자를 호출할 수 있도록 만들면, 한 번의 구현으로 여러 형태의 호출에 대응할 수 있다.
여기서 위임된 생성자의 본문은 위임한 생성자의 본문보다 앞서 실행된다. 위 예시에서 Student()나 Student(int grade)의 본문은 Student(int grade, int classroom)이 끝나고 난 후에 실행된다.
클래스에서 매개 변수가 있는 생성자가 하나라도 있는 경우 컴파일러는 자동으로 기본 생성자를 만들지 않는다. 이 때 default 키워드를 통해 컴파일러가 기본 생성자를 자동생성하게 강제할 수 있다.
class Student {
public:
int grade;
Student() = default;
Student(int grade) {
this->grade = grade;
}
};
다음과 같이 default 키워드를 통해 기본 생성자를 강제할 수 있다. 다만 멤버 변수는 초기화되지 않는다.
delete 키워드는 특정 형태의 생성자를 호출 할 수 없도록 한다. 예를 들어 어떤 클래스가 복사 생성자를 통해 호출되는 것을 막고 싶다고 가정하자. 이 때 다음과 같이 delete 키워드를 통해 복사 생성자 호출을 강제로 막을 수 있다.
class Student {
public:
int grade;
Student() = default;
Student(const Student &) = delete;
};
위의 경우 컴파일러는 복사 생성자가 호출되는 구문에서 컴파일 오류를 뱉는다.
매개변수 Type을 자동으로 바꾸지 못하게 하는 방식에도 delete 키워드를 쓸 수 있다.
class Student {
public:
int grade;
Student() = default;
void setGrade(int val) {
this->grade = val;
}
void setGrade(float val) = delete;
};
위 클래스의 setGrade는 int를 매개변수로 하지만 float을 입력으로 넣는다고 해도 잘 동작한다. 그 이유는 float에 대해 setGrade(float val)이 호출되기 때문인데, delete 키워드를 통해 이런 암시적인 변환을 완전히 막아 예기치 못한 입력과 동작을 컴파일 단계에서 방지할 수 있다.
// l-value를 리턴
int Add(int x, int y) {
return x + y;
}
// r-value를 리턴
int & inc(int &x) {
return ++x;
}
int main()
{
int x = 5; // x는 l-value, 5는 r-value
int y = x * 5; // y는 l-value, x * 5는 r-value
++x = 7; // ++x는 l-value, 7은 r-value
}
R-value references
R-value references는 && 연산자를 통해 가능하며 오직 temproray만을 참조할 수 있고 l-value 참조는 불가능하다.
int &&r1 = 10; // r-value reference
int &&r2 = Add(1, 2); // r-value reference
int x = 1; // l-value
int &&r3 = x // 오류
int &ref = x // l-value reference
const int &ref2 = 3 // r-value reference
여기에서 특이한 점은 r-value reference가 && 연산자와 const & 두 가지 형태로 모두 사용가능하다는 점이다. 그렇다면 아래와 같은 경우가 발생할 수 있다.
int print(int &x) {
std::cout << "print (int &x)";
}
int print(const int &x) {
std::cout << "print (const int &x)" << std::endl;
}
int print(int &&x) {
std::cout << "print (int &&x)" << std::endl;
}
int main()
{
const int& x = 3;
print(x);
print(3);
}
x는 r-value에 대한 reference이다. 하지만 위 코드를 실행했을 때는 서로 다른 print 함수가 실행된다.
여기서 print(int &&x)를 주석처리하면 print(const int &x)만 두 번 호출된다. print(3)에서 3이 const int&로 암시적으로 변환된 것으로 보인다. 그 반대로 print(const int& x)를 주석으로 처리했을 때는 컴파일 오류가 발생한다. const int&를 int &&로 변환은 할 수 없는 것으로 보인다.
즉 int&& -> const int&로의 변환은 가능하지만 그 역은 성립하지 않는다.
복사의 경우 원본 객체의 모든 값들을 복사하여 대상 객체에 새롭게 저장한다. 만약 복사 이후에 원본 객체가 필요없어지는 경우라면 이 과정은 불필요한 복사 연산을 한 셈이다. 예를 들어 어떤 객체를 리턴하는 함수 A가 있다고 하자.
Object B = A()의 구문에서 A()는 임시 객체를 리턴하고 이 객체를 복사 생성자를 통해 B로 복사하게 된다. 그 이후 이 임시 객체는 더 이상 필요가 없다. 만약 복사 대신 값을 바로 이동시킨다면 수행시간이 단축될 수 있다. 이것이 복사의 원리다.
대상 객체는 원본 객체가 가리키는 영역을 그대로 가리키게 되고, 원본 객체는 nullptr을 가리키게 된다. 이 방식을 통해 Move를 수행한다.
class Integer {
public:
int *val;
Integer() = default;
Integer(int val) {
this->val = new int(val);
}
~Integer() {
delete val;
}
};
Integer Add(const Integer& a, const Integer &b) {
Integer tmp;
tmp.val = new int(a.val + b.val);
return tmp;
}
int main()
{
Integer i1(1), i2(3);
i1.val = Add(i1, i2).val;
std::cout << i1.val;
}
이 구문에서 생성자는 총 4번 호출된다. i1과 i2에 대해 매개변수 생성자가 각 1번. tmp에 대해 기본 생성자가 1번. Add가 Integer 객체를 리턴할 때 tmp를 원본으로 한 복사 생성자가 한 번 호출된다. (tmp 객체의 life time은 Add가 끝날 때까지 뿐이다.)
Integer(Integer && obj) {
val = obj.val;
obj.val = nullptr;
}
다음과 같은 코드가 추가된 이후에는 Add가 Integer 객체를 리턴할 때 복사 생성자가 아니라 위의 생성자가 실행되게 된다. 이 때 새로운 객체는 원본의 val이 가리키는 주소를 그대로 가리키며, 대신 원본의 val은 nullptr를 가리키게 된다. 실제 데이터는 이동하지 않고 포인터만 달라졌기 때문에 복사에 비해 훨씬 빠르다.
이전 정리노트에서 소멸자 / 복사 생성자/ 복사 대입 연산자는 모두 사용자 정의이거나 존재하지 않아야 한다고 했는데, 이동 생성자에 대해서도 마찬가지다. 즉, 소멸자 / 복사 생성자 / 복사 대입 연산자 / 이동 생성자 / 이동 대입 연산자는 5가지 모두 구현되거나 모두 구현되지 않아야한다.
std::move는 어떤 객체를 실제로 이동하지는 않는다. 다만 대상 객체를 우측값 레퍼런스로 캐스팅해주는 역할만 한다. 해당 객체에 대해 이동 생성자나 이동 대입 생성자가 정의되어 있지 않으면 의미가 없다.
class Integer {
public:
int* val;
Integer() = default;
Integer(int val) {
this->val = new int(val);
}
Integer(Integer && obj) {
this->val = obj.val;
obj.val = nullptr;
}
};
int main()
{
Integer i1(1);
auto i2 = std::move(i1);
std::cout << *i1.val;
}
다음의 코드는 에러를 뱉는다. i2에 대해 i1를 이동 대입하고 난 후 i1의 포인터는 nullptr가 되었는데 이 상태에서 그 포인터 주소를 참조했기 때문이다.
합격왕 우여곡절 끝에 드디어 합격왕에 광고를 붙였다. 서비스를 시작한지 무려 4년이 지나서야 드디어 광고를 시작하게…
반복적인 일상 매일 아침 일어나 회사에 출근하고, 저녁을 먹고 돌아오는 일상의 반복이다. 주말은 가족을 보러…
Planning A well-structured plan has the following characteristics: First, the ultimate vision you aim to…
English The most common problem for English learners like myself is that we often use…
This website uses cookies.