전체적으로 아래의 글을 많이 참조하였으며, 구글에 나온 다양한 내용을 참고하였다.
#include <iostream>
class A {
int data_;
public:
A(int data) : data_(data) { std::cout << "일반 생성자 호출!" << std::endl; }
A(const A& a) : data_(a.data_) {
std::cout << "복사 생성자 호출!" << std::endl;
}
};
int main() {
A a(1); // 일반 생성자 호출
A b(a); // 복사 생성자 호출
// 그렇다면 이것은?
A c(A(2));
}
성공적으로 컴파일 하였다면
[ 실행 결과 ]
일반 생성자 호출!
복사 생성자 호출!
일반 생성자 호출!
뭔가 예상했던 것과 다르다.
아마 예상(정석?) 대로라면,
A(2) 를 만들면서 "일반 생성자 호출!" 이 한 번 출력되고, 생성된 임시 객체로 c 가 복사 생성되면서 "복사 생성자 호출!" 이 될 것이라고 예상할것이다.
그런데 왜 "일반 생성자 호출!" 이거 한 번 밖에 출력되지 않았을까?, 왜 복사 생성자가 출력되지 않았을까?
결론 부터 말하자면 이런게 Copy Elision 이다.
( 헷갈린다..곧대로 원칙대로 가는게 하나 없다. 성능상 최적화가 반드시 필요하겠지만.. 공부하는데 머리 아프게 하는 주범 중에 하나다. )
다시 생각해보면 컴파일러 입장에서
굳이 임시 객체를 한 번 만들고, 이를 복사 생성할 필요가 없다.
어차피 A(2) 로 똑같이 c 를 만들거면, 차라리 c 자체를 A(2) 로 만들어진 객체로 해버리는 것이랑 똑같기 때문이다.
따라서 똑똑한 컴파일러는 복사 생성을 굳이 수행하지 않고, 만들어진 임시로 만들어진 A(2) 자체를 c 로 만들어버린다.
이렇게, 컴파일러 자체에서 복사를 생략해 버리는 작업을 복사 생략(copy elision) 이라고 한다.
그럼 아래의 코드를 살펴보자.
길어 보이지만 그냥 String 클래스를 만들고, 복사생성자, 이동생성자 등등 언제 호출되는지 확인하고 싶은 코드이다.
#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();
};
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() { 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();
}
컴파일 하고 실행하면
이런 결과가 나온다.
중요한 건 여기다. 이 부분에서 두 개의 문자열을 더한 새로운 문자열로 str3 를 생성하고 있다.
MyString str3 = str1 + str2;
str1 + str2 실행시 아래의 함수가 호출되는데 평범한 문자열 + 주는 코드이다.
함수 내에서 생성된 MyString str 을 신경써서 보자.
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;
}
이렇게 리턴된 str은 str3를 생성하는데 전달 되어서, str3의 복사 생성자가 호출 된다.
하지만 위에서 살펴본바와 같이 굳이 str3의 복사 생성자를 또 호출할 필요가 없다.
만약에 str1 과 str2 의 크기가 엄청 컸다면, 쓸데 없는 복사를 두 번 하는데 상당한 자원이 소모 될 것이다.
그냥 (str1 + str2 ) 가 리턴한 객체를 str3 셈 치고 사용하면 불필요한 복사가 없을 것이다.
어??? 그러면 왜 이번에는 Copy Elision이 안일어 났지??
최적화충(Bugs) 우리 컴파일러님이 왜 이번에는 최적화를 안하셨을까?
라고 생각 들어야지 지금까지의 내용을 잘 이해한것이다.
C++ 17 이전에는 복사생략을 반드시 해라는 식이 아니라 복사 생략을 할 수 있다~ 라고 기준이 애매모호했다.
또한, 내용상 Copy Elision이 가능함에도 Non-movable에 대해서 컴파일 에러 발생하는 문제가 있었음.
그래서 C++17 부터는 일부 경우에 대해서 Copy Elision이 Guarnted 되었다.
#22.07.22 추가 Start ==========================================================================
복습하던 중 헷갈리는 내용과, 모두의 코드 글이 잘 못 된(?) 내용이 있어서 추가한다.
String str3 = str1 + str2
결과가 위 같이 나온 이유가 ( str1 + str2 ) 로 생성된 임시객체가 str3 에 불필요하게 복사 되기 때문이라고 나와있다.
그러나 사실은 컴파일러는 ( str1 + str2 ) 로 리턴된 임시객체가 복사생성자를 거쳐 str3가 생성되고 소멸되는 과정을 생략(최적화) 해준다.
아래의 코드를 보자.
#include <iostream>
using namespace std;
struct Foo
{
Foo() { std::cout << "Constructed\n"; }
Foo(const Foo&) { std::cout << "Copy-constructed\n"; }
Foo(Foo&&) { std::cout << "Move-constructed\n"; }
~Foo() { std::cout << "Destructed\n"; }
};
Foo RVO_F()
{
return Foo();
}
// Named Return Value Optimization
Foo NRVO_F()
{
Foo foo;
return foo;
}
int main()
{
// 아래 대입문에서 복사생성 생략
// "Constructed"
// "Destructed"
{
Foo rvo_foo = RVO_F();
}
cout << "===========================" << endl;
// "Constructed"
// "Copy-constructed or Move-constructed"
// "Destructed"
// "Destructed"
{
Foo NRVO_foo = NRVO_F();
}
}
RVO / NRVO 는 말 그대로 Return Value Optimize 이다.
함수의 리턴 과정에 생기는 복사생성/ 이동생성을 최적화 해주는것이다. ( cf. 이동생성자가 있으면 이동생성자 호출한다. )
Foo RVO_F()
{
return Foo();
}
Foo rvo_foo = RVO_F();
가 왜 아래와 같은 결과가 나왔는지 먼저 생각해보자.
-
// "Constructed" - RVO_F() 함수 안 로컬에서 객체( Foo() ) 생성
-
// "Move-Constructed" - 함수 반환값(Foo()) 임시객체로의 이동( or 복사)
-
// "Destructed" - 함수 안 로컬에서 생성한 객체 파괴
-
// "Move-Constructed" - main 함수에서 반환된 임시객체를 rvo_foo로의 이동( or 복사)
-
// "Destructed" - 반환된 임시객체 파괴
-
// "Destructed" - rvo_foo 파괴
그래서 결론적으로 1번, 6번 과정만 일어나게 된다.
Foo NRVO_F()
{
Foo foo;
return foo;
}
Foo NRVO_foo = NRVO_F();
풀 과정은 아래와 같을 것이다.
( 결과에서는 Copy-constructed 라고 나왔는데 실행할때 이동생성자를 막아줘서 그렇다.)
-
// "Constructed" - NRVO_F() 함수 안 로컬에서 객체( foo ) 생성
-
// "Move-Constructed" - 함수 반환값 임시객체로의 이동 : temp(foo);
-
// "Destructed" - 함수 안 로컬에서 생성한 객체 foo 파괴
-
// "Move-Constructed" - main 함수에서 반환된 임시객체(temp)를 NRVO_foo로의 이동
-
// "Destructed" - 반환된 임시객체(temp) 파괴
-
// "Destructed" - NRVO_foo 파괴
1~6까지의 풀 과정 중에서 어디가 생략된거고 어디가 생략되지 않은것일까?
* NRVO는 최적화 옵션 /O1부터 동작함을 잊지 말아야 한다.
(비주얼 스튜디오의 Debug 모드는 NRVO가 동작하지 않는다는 말이다)
NRVO는 Debug 모드일때는 작동하지 않는다.
그렇기 때문에 2~3 번 과정이 생략되지 않은것이다. 그리고 위 RVO의 예시와 마찬가지로 4~5 번 과정은 생략이 된것이다.
확실히 하기 위해 NRVO_foo 에 대입하지 말고 그냥 NRVO_F() 함수만 실행 시켜보자.
Foo NRVO_F()
{
Foo foo;
return foo;
}
// Foo NRVO_foo = NRVO_F();
NRVO_F();
만약 이게 함수의 리턴값 대입 과정에서 Copy Elision 이 일어나지 않았던 거라면, 그냥 리턴 대입과정을 빼고 그냥 NRVO_F() 함수만 호출했을때
-
// "Constructed"
-
// "Destructed"
만 출력되야 되는데 그렇지 않은것을 볼 수 있다.
결론적으로 RVO / NRVO 는 함수의 리턴과정에서의 최적화를 말하는것이고, 디버그 모드일때에는 NRVO 는 일어나지 않는다이다.
실제로 Relase 모드로 실행시키면 아래의 결과처럼 NRVO도 발생하는것을 볼 수있다.
그렇기 때문에 모두의 코드에서
String str3 = str1 + str2 ;
의 결과가 아래인 이유가 str1 + str2 로 리턴된 임시객체가 str3에 복사생성 되기 때문이 아니라
str1 + str2 과정에서 operator+() 함수에서 디버그 모드이기 때문에 NRVO가 일어나지 않았기 때문이다.
실제로 Release 모드로 변경하고 실행하면 NRVO가 된 아래와 같은 결과가 나온다.
#22.07.22 추가 End ============================================================================
- RVO , NRVO 를 공부하면서 아래의 블로그들을 참고하였다.
(중요한 내용들이 많이 있다. )
https://hyo-ue4study.tistory.com/346
http://egloos.zum.com/sweeper/v/3204454 (링크가 안타진다 그냥 구글에다가 수까락의 프로그래밍 이야기 Guaranteed Copy Elision ) 치면 나온다.
요약하자면, 이동생성자나 복사생성자를 delete 하면 RVO/NRVO 최적화가 일어날때 삭제된 함수라고 오류가 뜬다.
(분명히 최적화가 일어나야되는데 말이다.)
cf) Foo(const Foo&&) : 이동 생성자가 delete 된게 아니라 애초에 정의되지 않았으면 Copy Construtor 가 작동 된다.
delete 되었다면 있었는데 못쓰게 막아놓은거니깐 쓰려고 하면 오류가 뜨는거다.
#include <iostream>
struct Foo
{
Foo() { std::cout << "Constructed\n"; }
Foo(const Foo& ) { std::cout << "Copy Constructed\n"; }
Foo(const Foo&& ) noexcept { std::cout << "Move Constructed\n"; }
//Foo(const Foo&) = delete;
//Foo(const Foo&&) = delete;
~Foo() { std::cout << "Destructed\n"; }
};
Foo f()
{
Foo b;
return b;
}
Foo r_f()
{
return Foo();
}
int main()
{
// Foo foo; // 객체 생성 -> 소멸
//
Foo foo = r_f(); // 원래는 생성 -> 이동 -> 소멸 -> 이동 -> 소멸 -> 소멸
// 근데 생성 -> 소멸 만 작동.
Foo foo = f(); // 원래는 생성 -> 이동 -> 소멸 -> 이동 -> 소멸 -> 소멸
// 근데 생성 -> 이동 -> 소멸 -> 소멸 만 작동.
}
참고로, NRVO는 최적화 옵션 /O1부터 동작함을 잊지 말아야 한다.
(비주얼 스튜디오의 Debug 모드는 NRVO가 동작하지 않는다는 말이다)
Release 모드 | Debug 모드 | |
RVO | 최적화 O | 최적화 O |
NRVO | 최적화 O | 최적화 X |
// Return Value Optimization
Foo RVO_F()
{
return Foo();
}
// Named Return Value Optimization
Foo NRVO_F()
{
Foo foo;
return foo; // 리턴용 임시객체 temp(foo)생성.
// 이후 foo 는 없어지고 temp가 리턴
}
int main()
{
// 복사생성 생략
// "Constructed"
// "Destructed"
{
Foo rvo_foo = RVO_F();
}
// 복사생성 생략 ( 릴리즈 모드일때만 )
// "Constructed"
// "Destructed"
{
Foo nrvo_foo = NRVO_F();
}
}
struct Foo
{
Foo() { std::cout << "Constructed\n"; }
Foo(const Foo &) = delete;
Foo(const Foo &&) = delete;
~Foo() { std::cout << "Destructed\n"; }
};
Foo f()
{
return Foo();
}
int main()
{
Foo foo = f();
}
// E1776 : 함수 "Foo::Foo(Const Foo&&)" 을(를) 참조할 수 없습니다. 삭제된 함수입니다.
// E1776 : 함수 "Foo::Foo(Const Foo&&)" 을(를) 참조할 수 없습니다. 삭제된 함수입니다.
분명히 Copy Elision 이 되야되는데 이동생성자를 delete 해놔서 이동이 불가능한 타입 (ex. unique_ptr, 쓰레드 ) 같은 경우에 copy elision이 적용되지 않고, 삭제된 이동생성자를 오매불망 찾으며 컴파일 에러를 발생하는 문제가 있었다.
=> 그래서 C++17 부터 Guaranteed Copy Elision 이 도입 되었다.
C++17로 바꾸면 RVO 에 대해서는 복사 생성자나 이동생성자를 delete 했음에도 Copy Elision이 일어난다.(최적화)
하지만 아직 RVO 에만 가능하고 , C++17에서도 NRVO에 대해서는 Guaranteed Copy Elision이 적용되지 않았다.
그래서 NRVO 에서는 동일한 에러가 나온다. ( 그리고 컴파일 하기전부터 애시당초에 return foo; 에서 에러가 나온다)
// E1776 : 함수 "Foo::Foo(Const Foo&&)" 을(를) 참조할 수 없습니다. 삭제된 함수입니다.
임시객체 참고 >> https://hyo-ue4study.tistory.com/346
멀리 돌아왔다.
그래서 다시 돌아와서 위의 MyString str3 = str1 + str2 의 문제(Copy Elision 을 안해주는 문제= 불필요한 복사생성을 한번 더 하 는거) 를 어떻게 해결해야 하나?
- 내 생각 1 : release 모드로 해주면 된다. ( NRVO가 될테니깐 )
* 7.22 추가 된 내요에서도 말했듯이 컴파일러가 불필요한 복사 생성자를 호출하는것은 NRVO 과정이 일어나지 않아서이지, str1 + str2가 리턴한 임시객체가 str3 로 이동(복사)되기 때문이 아니다.
또 한가지 증거는 이동생성자를 delete 해놓고, 코드를 보면
이렇게 operator+ 에서 에러가 뜨는것을 알 수 있다.
이 말은 이동생성자가 없으면 Copy 생성자가 호출되는데 이동생성자가 있어 호출하려고 봤더니 delete 인것이다.
아래서부터는 String str3 = str1 + str2 ; 에서 복사 생략이 일어나지 않는다라고 생각하고 설명된 모두의코드 내용이다.
물론 지금은 디폴트 이동생성자가 있지만, 이동 이동생성자를 만들어서 string_content가 가리키는 값을 str3가 가리키게 하면 좋을것이다. ( 그렇다고 복사생성 과정이 생략 되지는 않는다는 말이다. 이동생성으로 바뀌는것일 뿐 )
[ 디폴트 이동생성자 생성 조건 ]
- 직접 정의한 복사 생성자나 복사 대입 연산자나 이동 대입 연산자나 소멸자가 있으면 암시적 생성이 안 된다.
- 비정적 멤버변수가 이동될 수 없으면 제거된다. (delete, 모호, 접근불가)
- 부모 클래스가 이동될 수 없으면 제거된다. (delete, 모호, 접근불가)
- 부모 클래스의 소멸자가 delete됐거나, 접근불가이면 제거된다.
위와 같이 간단하다.
(str1 + str2) 의 임시로 생성된 객체의 string_content 가리키는 문자열의 주소값을 str3 의 string_content 로 해주면 된다.
문제는 이렇게 하게 되면, 임시 객체가 소멸 시에 string_content 를 메모리에서 해제하게 되는데, 그렇게 되면 str3 가 가리키고 있던 문자열이 메모리에서 소멸되게 된다.
따라서 이를 방지 하기 위해서는, 임시 생성된 객체의 string_content 를 nullptr 로 바꿔주고, 소멸자에서 string_content 가 nullptr 이면 소멸하지 않도록 해주면 된다. 일단은 간단해 보인다.
하지만, 이 방법은 기존의 복사 생성자에서 사용할 수 없다.
왜냐하면 우리는 인자를 const MyString& 으로 받았기 때문에, 인자의 값을 변경할 수 없게 된다. 즉 임시 객체의 string_content 값을 nullptr 로 수정할 수 없기에 문제가 된다.
이와 같은 문제가 발생한 이유는 const MyString& 이 좌측값과 우측값 모두 받을 수 있다는 점에서 비롯되었다. ( 우측값을 받을때 변경 할 수 있게 하면 된다. )
string_content = str.string_content;
// 임시 객체 소멸 시에 메모리를 해제하지
// 못하게 한다.
str.string_content = nullptr;
MyString::~MyString() {
if (string_content) delete[] string_content;
}
인자로 받은 임시 객체가 소멸되면서 자신이 가리키고 있던 문자열을 delete 하지 못하게 해야 한다.
만약에 그 문자열을 지우게 된다면, 새롭게 생성된 문자열 str3 도 같은 메모리를 가리키고 있기 때문에 str3 의 문자열도 같이 사라지게 된다.
MyString::~MyString() {
if (string_content) delete[] string_content;
}
그리고 물론 소멸자 역시 바꿔줘야만 한다. string_content 가 nullptr 가 아닐 때 에만 delete 를 하도록
우측값 레퍼런스의 재미있는 특징으로 레퍼런스 하는 임시 객체가 소멸되지 않도록 붙들고 있는다는 점이다.
예를 들어서,
MyString&& str3 = str1 + str2;
str3.println();
의 경우 str3 이 str1 + str2 에서 리턴되는 임시 객체의 레퍼런스가 되면서 그 임시 객체가 소멸되지 않도록 한다.
실제로, 아래 println 함수에서 더해진 문자열이 잘 보여진다.
이동 생성자 작성 시 주의할 점
C++ 컨데이너들에 유저 클래스에 넣기 위해서는 이동생성자를 반드시 noexcept 로 명시해야한다.
vector 를 예를 들어서 생각해봅시다. vector 는 새로운 원소를 추가 할 때, 할당해놓은 메모리가 부족하다면, 새로운 메모리를 할당한 후에, 기존에 있던 원소들을 새로운 메모리로 옮기게 됩니다.
복사 생성자를 사용 하였을 경우 위와 같이 원소가 하나씩 하나씩 복사 됩니다. 그런데 만약에 이 복사 생성하는 과정에서 예외가 발생하였다고 해봅시다.
해결책은 간단합니다. 새로 할당해놓은 메모리를 소멸시켜 버린 후, 사용자에게 예외를 전달하면 됩니다. 새로 할당한 메모리를 소멸 시켜 버리는 과정에서 이미 복사된 원소들도 소멸 되버리므로 자원이 낭비되는 일도 없을 것입니다.
반면에 이동 생성자를 사용하였을 경우는 어떨까요? 이동 생성하는 과정에서 예외가 발생했더라면, 꽤나 골치아파집니다. 복사 생성을 하였을 경우 새로 할당한 메모리를 소멸시켜 버려도, 기존의 메모리에 원소들이 존재하기 때문에 상관 없지만, 이동 생성의 경우 기존의 메모리에 원소들이 모두 이동되어서 사라져버렸기에, 새로 할당한 메모리를 섯불리 해제해버릴 수 없기 때문입니다.
따라서 vector 의 경우 이동 생성자에서 예외가 발생하였을 때 이를 제대로 처리할 수 없습니다.
이는 C++ 의 다른 컨테이너들(Container) 들도 동일합니다.
이 때문에 vector 는 이동 생성자가 noexcept 가 아닌 이상 이동 생성자를 사용하지 않습니다.
#include <iostream>
#include <cstring>
#include <vector>
class MyString {
char *string_content; // 문자열 데이터를 가리키는 포인터
int string_length; // 문자열 길이
int memory_capacity; // 현재 할당된 용량
public:
MyString();
// 문자열로 부터 생성
MyString(const char *str);
// 복사 생성자
MyString(const MyString &str);
// 이동 생성자
MyString(MyString &&str);
~MyString();
};
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;
}
int main() {
MyString s("abc");
std::vector<MyString> vec;
vec.resize(0);
std::cout << "첫 번째 추가 ---" << std::endl;
vec.push_back(s);
std::cout << "두 번째 추가 ---" << std::endl;
vec.push_back(s);
std::cout << "세 번째 추가 ---" << std::endl;
vec.push_back(s);
}
기껏 이동 생성자를 만들어 놓았는데 , vector가 확장할 때 마다 복사 생성자를 이용한다.
생성자 호출 !
첫 번째 추가 ---
복사 생성자 호출 !
두 번째 추가 ---
복사 생성자 호출 !
복사 생성자 호출 !
세 번째 추가 ---
복사 생성자 호출 !
복사 생성자 호출 !
복사 생성자 호출 !
이동 생성자에 noexcept 를 추가하면
MyString::MyString(MyString &&str) noexcept
생성자 호출 !
첫 번째 추가 ---
복사 생성자 호출 !
두 번째 추가 ---
복사 생성자 호출 !
이동 생성자 호출 !
세 번째 추가 ---
복사 생성자 호출 !
이동 생성자 호출 !
이동 생성자 호출 !
'Language & etc > C++' 카테고리의 다른 글
std::Move, perfect forwarding - ① (0) | 2022.06.19 |
---|---|
전방 선언 (Forward Declaration) #22.11.22일 복습 (0) | 2022.06.10 |
타입 변환 연산자 (0) | 2022.04.17 |
변환 생성자 (feat. explicit, delete) (0) | 2022.04.17 |
const class ( 상수 클래스 ) (0) | 2022.04.17 |