복사, 이동 생성자 등을 정의하는 이유
유저가 직접 동적할당하는 new의 경우에 얕은 복사를 방지하기 위해서이다.
vector, string, 스마트 포인터는 이와 무관하다.
클래스에서 컴파일러가 알아서 만들어주는 메소드
1. 생성자
2. 소멸자
3. 복사 / 이동 생성자
4. 복사 / 이동 연산자
유저가 클래스 내의 멤버 변수로 포인터를 사용한다면
소멸자, 복사 / 이동 연산자 및 할당자를 관리해야 한다.
초기화 할 때는 습관적으로 brace initialization - {} 을 사용하도록 하는 것이 좋다.
이는 C++11에서 추가된 우측값 레퍼런스와 연관이 있다.
처음에 보면 다소 생소할 수 있는 개념이지만 천천히 학습하다보면 이해 가능하다.
복사 생략(Copy Elision)
#include <iostream>
using namespace std;
class A {
int data_;
public :
A(int data) : data_(data) { cout << "일반 생성자 호출!" << endl; }
A(const A& a) : data_(a.data_) {
cout << "복사 생성자 호출!" << endl;
}
};
int main() {
ios::sync_with_stdio(false);
cin.tie(NULL);
cout.tie(NULL);
A a(1); // 일반 생성자 호출
A b(a); // 복사 생성자 호출
// 그렇다면 이것은?
A c(A(2));
return 0;
}
이를 실행하면 다음과 같이 출력된다.
뭔가 예상한 것과 조금 다를 수 있는 결과였다.
// 그렇다면 이것은?
A c(A(2));
이 부분에서 "일반 생성자 호출!"만 출력이 되었다.
아마 정석대로 였다면,
A(2)
를 만들면서 "일반 생성자 호출!"이 한 번 출력되어야 되고,
생성된 임시 객체로 c가 복사 생성되면서 "복사 생성자 호출!"이 될 것이기 때문이다.
그런데 왜 "일반 생성자 호출!" 한 번 밖에 출력되지 않았을까? 복사 생성자가 왜 불리지 않았을까?
사실 생각해보면 굳이 임시 객체를 한 번 만들고, 이를 복사 생성할 필요가 없다.
어짜피 A(2)로 똗같이 c를 만들 것이라면,
차라리 c 자체를 A(2)로 만들어진 객체로 해버리는 것과 같기 때문이다.
따라서 똑똑한 컴파일러는 복사 생성을 굳이 수행하지 않고,
임시로 만들어진 A(2)자체를 c로 만들어버린다.
이렇게, 컴파일러 자체에서 복사를 생략해버리는 작업을 복사 생략(copy elision)이라고 한다.
컴파일러가 복사 생략하는 경우는 (함수의 인자가 아닌)
함수 내부에서 생성된 객체를 그래도 리턴할 때, 수행할 수 있다는 것이다.
물론 C++ 표준을 읽어보면 반드시 복사 생략을 해라 라는 식이 아니라,
복사 생략을 할 수도 있다 라는 뜻으로 써 있었다.
C++ 17 부터 일부 경우에 대해서
(예를 들어서 함수 내부에서 객체를 만들어서 return 할 경우)
반드시 복사 생략을 해야되는 것으로 바뀌었다.
자세한 내용은 https://en.cppreference.com/w/cpp/language/copy_elision 를 참조
즉, 경우에 따라서는 복사를 생략해도 되는 경우에는, 복사 생략을 하지 않을 수 있다는 뜻이다.
#include <iostream>
#include <cstring>
class MyString {
char* string_content; // 문자열 데이터를 가리키는 포인터
int string_length; // 문자열 길이
int memory_capacity; // 현재 할당된 용량
public:
MyString();
// 문자열로 부터 생성
MyString(const char* str);
// 복사 생성자
MyString(const MyString& str);
void reserve(int size);
MyString operator+(const MyString& s);
~MyString();
int length() const;
void print();
void println();
};
int main() {
MyString str1("abc");
MyString str2("def");
std::cout << "-------------" << std::endl;
MyString str3 = str1 + str2;
str3.println();
return 0;
}
MyString::MyString()
{
std::cout << "생성자 호출 ! " << std::endl;
string_length = 0;
memory_capacity = 0;
string_content = nullptr;
}
MyString::MyString(const char* str)
{
std::cout << "생성자 호출 ! " << std::endl;
string_length = strlen(str);
memory_capacity = string_length;
string_content = new char[string_length];
}
MyString::MyString(const MyString& str)
{
std::cout << "복사 생성자 호출 ! " << std::endl;
string_length = str.string_length;
memory_capacity = str.string_length;
string_content = new char[string_length];
for (int i = 0; i != string_length; i++)
string_content[i] = str.string_content[i];
}
MyString::~MyString(){ delete[] string_content; }
void MyString::reserve(int size)
{
if (size > memory_capacity) {
char* prev_string_content = string_content;
string_content = new char[size];
memory_capacity = size;
for (int i = 0; i != string_length; i++)
string_content[i] = prev_string_content[i];
if (prev_string_content != nullptr) delete[] prev_string_content;
}
}
MyString MyString::operator+(const MyString& s)
{
MyString str;
str.reserve(string_length + s.string_length);
for (int i = 0; i < string_length; i++)
str.string_content[i] = string_content[i];
for (int i = 0; i < s.string_length; i++)
str.string_content[string_length + i] = s.string_content[i];
str.string_length = string_length + s.string_length;
return str;
}
int MyString::length() const { return string_length; }
void MyString::print()
{
for (int i = 0; i != string_length; i++) std::cout << string_content[i];
}
void MyString::println()
{
for (int i = 0; i != string_length; i++) std::cout << string_content[i];
std::cout << std::endl;
}
굳이 str3의 복사 생성자를 또 호출할 필요 없이,
똑같이 복사해서 생성할 것이면 이미 생성된 str1 + str2가 리턴한 객체를 str3인 셈 치고 사용하면 된다.
이전의 예제에서는 컴파일러가 불필요한 복사 생성자 호출을 복사 생략을 통해 수행하지 않았지만,
이 예제의 경우, 컴파일러가 복사 생략 최적화를 수행하지 않았다.
위 과정을 간단한 그림으로 살펴보면 아래와 같다.
이는 C++의
"Lvalue와 Rvalue와 연관성이 있다."
Lvalue는 보통 주소값을 취할 수 있는 값을 좌측값이라고 한다.
즉 & 연산자를 통해 알아 낼 수 있는 값을 말한다.
그럼 Rvalue는 뭘까? 우측값으로 이름에서도 알 수 있듯이
식의 오른쪽에만 와야하는 값으로 주소값을 취할 수 없는 값을 말한다.
여태껏 우리가 다루었는 레퍼런스는 '좌측값' 에만 레퍼런스를 가질 수 있다.
예를 들어 a의 경우는 좌측값이고 = 9는 우측값이다.
때문에
int a; // a 는 좌측값
int& l_a = a; // l_a 는 좌측값 레퍼런스
int& r_b = 3; // 3 은 우측값. 따라서 오류
다음과 같은 코드로 설명할 수 있다.
이와 같이 & 하나를 이용하여 정의하는 레퍼런스를 좌측값 레퍼런스라 부른다.
좌측값 레퍼런스 자체 역시도 좌측값이 된다.
int& func1(int& a) { return a; }
int func2(int b) { return b; }
int main() {
int a = 3;
func1(a) = 4;
std::cout << &func1(a) << std::endl;
int b = 2;
a = func2(b); // 가능
func2(b) = 5; // 오류 1
std::cout << &func2(b) << std::endl; // 오류 2
}
다음과 같은 예제를 컴파일 했다면 1, 2 줄에서 각각의 다음과 같은 오류를 확인할 수 있다.
일단 func1 의 경우 좌측값 레퍼런스를 리턴한다.
앞서, 좌측값 레퍼런의 경우 좌측값에 해당하기 때문에
func1(a) = 4;
위의 경우 func(a)가 리턴하는 레퍼런스의 값을 4로 해라 라는 의미로,
실제 변수 a의 값이 바뀌게 된다. 또한, func(a)가 좌측값 레퍼런스를 리턴하므로,
그 리턴값의 주소값 역시 취할 수 있다.
하지만 func2를 살펴보면, func2의 경우, 레퍼런스가 아닌, 일반적인 int값을 리턴하고 있다.
이 때 리턴되는 값은
a = func2(b);
이 문장이 실행될 때 잠깐 존재할 뿐 그 문장 실행이 끝나면 사라지게 된다.
즉, 실체가 없는 값이라는 뜻이다. 따라서 func2(b)는 우측값이 된다.
따라서 위와 같이 우측값이 실제 표현식의 오른쪽에 오는 경우에는 가능하지만,
func2(b) = 5;
와 같은 우측값이 왼족에 오는 경우는 가능하지 않는다.
앞선 예제에서
예시 1
MyString str3 = str1 + str2;
예시 2
MyString str3(str1.operator+(str2));
를 다시 살펴본다면, 예시 1과 예시 2의 코드는 동일하다.
operator+ 의 정의를 살펴보면,
MyString MyString::operator+(const MyString &s)
로 우측값을 리턴하고 있는데, 이 우측값이 어떻게 좌측값 레퍼런스를 인자로 받는,
MyString(const MyString &str);
를 호출할 수 있었을까?
이는 &가 좌측값 레퍼런스를 의미하지만 예외적으로는
const T&
의 타입에 한해서만, 우측 값도 레퍼런스로 받을 수 있다.
그 이유는 const 레퍼런스이기 때문에 임시로 존재하는 객체의 값을 참조만 할 뿐 이를 변경할 수 없기 떄문이다.
그렇다면 이동은 어떻게 하는가?
위와 같은 방식으로 간단히 작동한다.
str3 생성 시에 임의로 생성된 객체의 string_content 를 가리키는 문자열의 주소 값을
str3의 string_content 로 해주면 된다.
문제는 이렇게 하게 되면, 임시 객체가 소멸 시에 string_content를 메모리에서 해제하게 되는데,
그렇게 하게 되면 str3가 가리키고 있던 문자열이 메모리에서 소멸되게 된다.
따라서 이를 방지하기 위해서는,
임시 생성된 객체의 string_content를 nullptr로 바꿔주고, 소멸자에서 string_content가 nullptr이면
소멸하지 않도록 해주면 된다.
하지만, 이 방법은 기존의 복사 생성자에서 사용할 수 없다.
왜나하면 작성된 코드는 인자를 const MyString& 으로 받았기 때문이다.
인자의 값을 변경할 수 없게 되었다.
즉 임시 객체의 string_content값을 수정할 수 없기에 문제가 되는 것이다.
이와 같은 문제가 발생한 이유는 const MyString& 이 좌표값과 우측값 모두 받을 수 있다는 점에서 비롯되었다.
그렇다면, 좌측값 말고 우측값만 특이적으로 받을 수 있는 방법은 무엇일까?
바로 C++ 11 부터 제공하는 우측값 레퍼런스를 이용하면 된다.
(참고로 C++ 11 가 기본으로 설정되어 있지 않는 컴파일러는 사용이 불가능하다.
VS 2017 버전의 경우 자동으로 사용 설정 되어 있다.)
우측값 레퍼런스
#include <iostream>
#include <cstring>
class MyString {
char *string_content; // 문자열 데이터를 가리키는 포인터
int string_length; // 문자열 길이
int memory_capacity; // 현재 할당된 용량
public:
MyString();
// 문자열로 부터 생성
MyString(const char *str);
// 복사 생성자
MyString(const MyString &str);
// 이동 생성자
MyString(MyString &&str);
void reserve(int size);
MyString operator+(const MyString &s);
~MyString();
int length() const;
void print();
void println();
};
MyString::MyString() {
std::cout << "생성자 호출 ! " << std::endl;
string_length = 0;
memory_capacity = 0;
string_content = nullptr;
}
MyString::MyString(const char *str) {
std::cout << "생성자 호출 ! " << std::endl;
string_length = strlen(str);
memory_capacity = string_length;
string_content = new char[string_length];
for (int i = 0; i != string_length; i++) string_content[i] = str[i];
}
MyString::MyString(const MyString &str) {
std::cout << "복사 생성자 호출 ! " << std::endl;
string_length = str.string_length;
memory_capacity = str.string_length;
string_content = new char[string_length];
for (int i = 0; i != string_length; i++)
string_content[i] = str.string_content[i];
}
MyString::MyString(MyString &&str) {
std::cout << "이동 생성자 호출 !" << std::endl;
string_length = str.string_length;
string_content = str.string_content;
memory_capacity = str.memory_capacity;
// 임시 객체 소멸 시에 메모리를 해제하지
// 못하게 한다.
str.string_content = nullptr;
}
MyString::~MyString() {
if (string_content) delete[] string_content;
}
void MyString::reserve(int size) {
if (size > memory_capacity) {
char *prev_string_content = string_content;
string_content = new char[size];
memory_capacity = size;
for (int i = 0; i != string_length; i++)
string_content[i] = prev_string_content[i];
if (prev_string_content != nullptr) delete[] prev_string_content;
}
}
MyString MyString::operator+(const MyString &s) {
MyString str;
str.reserve(string_length + s.string_length);
for (int i = 0; i < string_length; i++)
str.string_content[i] = string_content[i];
for (int i = 0; i < s.string_length; i++)
str.string_content[string_length + i] = s.string_content[i];
str.string_length = string_length + s.string_length;
return str;
}
int MyString::length() const { return string_length; }
void MyString::print() {
for (int i = 0; i != string_length; i++) std::cout << string_content[i];
}
void MyString::println() {
for (int i = 0; i != string_length; i++) std::cout << string_content[i];
std::cout << std::endl;
}
int main() {
MyString str1("abc");
MyString str2("def");
std::cout << "-------------" << std::endl;
MyString str3 = str1 + str2;
str3.println();
}
우측값의 레퍼런스를 정의하기 위해서는 좌측값과 달리 &를 두 개 사용해서 정의해야 한다.
즉, 위 생성자의 경우 MyString 타입의 우측값을 인자로 받고 있다.
그렇다면 과연 str 자체는 우측 값일까 좌측 값일까? 당연히 좌측값이다.
실체가 있기 때문이다. 다시 말해 str은 타입이 'Mystring의 우측값 레퍼런스'인 좌측값이라고 보면 된다.
따라서 표현식의 좌측에 올 수 있다.
++
push_back , emplace_back
push_back , emplace_back은
두 함수 모두 vector컨테이너가 주어졌을 때 한 칸을 늘리고 해당 값을 넣어주게 된다.
그러나 내부적으로 동작하는 것은 조금 다른데,
push_back의 경우 메모리를 이동하거나 임시 객체를 이용하여
임시객체가 생성되고 넣어준 뒤에 임시 객체가 삭제되는 호출이 있다.
대신 emplace_back은 받은 인수를 이용해 내부에서 생성자를 통한 객체 생성 후
추가를 하기 때문에 임시 객체의 생성, 파괴가 없어서 push_back보다 효율적일 수 있다.
참고 사이트
https://www.youtube.com/watch?v=q60G_4nZdoU
https://velog.io/@kwt0124/%EB%B3%B5%EC%82%AC-%EC%9D%B4%EB%8F%99-%EC%83%9D%EC%84%B1%EC%9E%90
'프로그래밍 언어 > C & C++ 정리' 카테고리의 다른 글
const int *, const int* const, int* const (0) | 2024.04.09 |
---|---|
다이나믹 캐스트 (0) | 2024.04.04 |
STL 컨테이너 (0) | 2024.03.27 |
STL - 제네릭 프로그래밍과 템플릿 (3) | 2024.02.06 |
정적 멤버와 상수 멤버 (0) | 2024.02.05 |