[EMC++] Item 0. 서론 및 기본 용어 정리
Item 0. 서론
Effective Modern C++를 공부하면서 내가 알고 있는 C++ 문법들과 함께 정리하려고 한다.
단순히 책에 있는 내용을 옮기는 것이 아니라 내가 이해한 내용과 생각을 정리하는 것이 목표이다.
먼저 기본적인 C++ 용어를 정리하고 넘어가겠다.
포인터(Pointer)
포인터는 메모리의 주소값을 저장하는 변수이다. 말 그대로 변수의 주소를 가리키는 변수라고 할 수 있다.
포인터 변수에 *
(역참조 연산자)를 붙이면 포인터 변수가 가리키고 있는 주소의 값을 반환한다.
변수에 &
(주소 연산자)를 붙이면 해당 변수의 주소를 반환한다.
int n = 10;
int* p = &n;
std::cout << n << std::endl; // 10
std::cout << *p << std::endl; // 10
std::cout << p << std::endl; // 0x16f38b968
포인터(Pointer)와 배열(Array)의 차이
배열은 일종의 포인터기 때문에 포인터도 배열처럼 사용할 수 있다.
포인터 변수와 배열 변수는 모두 데이터의 시작 주소를 가리키고 있으며 []
(인덱스 연산자)를 사용해서 접근할 수 있다.
하지만 포인터와 배열은 차이가 존재한다.
- 배열의 변수는 주소값을 변경할 수 없는 상수 포인터(const pointer)이다.
- 배열의 사이즈는 배열의 총 크기지만 포인터의 사이즈는 포인터의 크기이다.
- 배열은 리터럴을 이용한 문자열 초기화가 가능하지만, 포인터는 불가능하다.
int a[] = {1, 2, 3, 4};
int* pa = a;
std::cout << sizeof(a) << std::endl; // 4 * 4 = 16
std::cout << sizeof(pa) << std:: endl; // 8 (64bit 주소값)
int b = 10;
a = &b; // 배열은 상수 포인터라 불가능
pa = &b; // 가능
char str[] = "hello"; // "hello" 리터럴을 배열에 복사 (스택 영역)
char* pstr = "hello"; // "hello" 리터럴을 가리킴 (데이터 영역)
str[1] = 'a'; // 가능
pstr[1] = 'a'; // 불가능
// 리터럴을 이용해서 문자열을 초기화 할거면 const를 붙여서 사용하는 것을 권장
const char* pstr = "hello";
좌측값(lvalue), 우측값(rvalue)
주소값을 취할 수 있는 값을 lvalue, 주소값을 취할 수 없는 값을 rvalue라고 한다.
lvalue는 =
(등호)의 좌변, 우변에 모두 올 수 있지만 rvalue는 우변에만 올 수 있다.
int a = 3; // a는 좌측값, 3은 우측값
참조자(Reference)
reference는 변수의 별명이다. 즉, 변수와 완전히 동일하게 동작한다.
reference는 내부적으로 포인터처럼 참조하는 변수의 주소값을 저장하고 있다.
reference는 선언할 때 반드시 참조할 변수를 지정해야 하며 한번 참조할 변수를 정하면 변경할 수 없다.
또한, 포인터와 달리 이중 reference는 불가능하며 리터럴을 참조하고 싶으면 const
키워드를 붙어야 한다.
reference는 값의 종류에 따라 lvalue reference와 rvalue reference로 나눌 수 있다.
lvalue reference
reference를 선언할 때 &
를 붙이면 lvalue reference 이다.
lvalue reference는 lvalue만 참조할 수 있다.
만약 rvalue를 참조하고 싶으면 const
키워드를 붙여야 한다.
int a = 10; // a는 lvalue
int& ref_a = a; // ref_a는 lvalue reference
int& ref_a = 10; // rvalue를 참조하려고 해서 오류 발생
const int& ref_a = 10; // const를 붙이면 rvalue도 참조 가능
rvalue referenece
reference를 선언할 때 &&
를 붙이면 rvalue reference 이다.
rvalue reference는 rvalue만 참조할 수 있다.
rvalue reference는 참조하는 임시 객체를 소멸하지 않게 유지 시킬 수 있다.
rvalue reference는 주소값을 취할 수 있으므로 rvalue가 아닌 lvalue이다.
int b = 10; // b는 lvalue
int&& ref_b = b; // lvalue를 참조하려고 해서 오류 발생
int&& ref_b = 10; // ref_b는 rvalue reference
int plusFive(int n) {
return n + 5;
}
plusFive(5); // return 값이 10인 임시 객체(rvalue)가 생성. 이 라인이 끝나면 임시 객체는 소멸함
int&& n = plusFive(5); // rvalue reference를 통해 임시 객체를 생성하면 이 라인이 끝나도 소멸되지 않음
std::cout << &n << std::endl; // 임시 객체의 주소에도 접근할 수 있음
매개변수(Parameter), 인자(Argument)
매개변수(parameter)는 함수의 정의 부분에 나열된 변수(variable)를 의미한다.
인수(argument)는 함수가 호출될 때 함수로 전달해주는 값(value)을 의미한다.
// a와 b는 parameter
int sum(int a, int b) {
return a + b;
}
int result = sum(1, 2); // 1, 2는 argument
Call By Value, Call By Pointer, Call By Reference
call by value는 함수가 호출되면 argument의 값을 parameter로 복사하는 방식이다.
call by pointer와 call by reference는 함수가 호출되면 argument의 주소를 parameter로 전달하는 방식으로 주소를 전달하기 때문에 함수 내부에서 parameter를 통해 참조하는 변수의 값을 변경할 수 있다.
call by pointer와 call by reference는 어셈블리 레벨에서는 똑같지만, 코드를 구현하는데 있어서는 차이가 존재한다.
- call by reference는
null
을 허용하지 않고*
(참조 연산자) 없이 일반 변수처럼 사용할 수 있어서 가독성이 좋다는 장점이 있다. - call by pointer는 주소값을 변경할 수 있을 정도로 자유도가 높지만 필요한 상황이 아니면 불필요한 실수를 방지하기 위해 reference를 쓰는 것을 권장한다.
// Call By Value
void funcV(int a){
a = a + 1;
}
// Call By Pointer
void funcP(int* a){
*a = *a + 1;
}
// Call By Reference
void funcR(int& a){
a = a + 1;
}
int n = 0;
funcV(n);
std::cout << n << std::endl; // 0
funcP(&n);
std::cout << n << std::endl; // 1
funcR(n);
std::cout << n << std::endl; // 2
이동 문법(Move Semantics)
앞서 배운 rvalue, lvalue는 모두 move sementics를 위한 내용이라 봐도 무방하다.
move semantics은 객체를 생성하거나 대입할 때 이름이 있는 객체는 복사하고 이름이 없는 임시 객체는 이동하여 불필요한 복사를 방지하는 기능이다.
move semantics는 이동 생성자(Move Constructor)와 이동 대입 연산자(Move Assignment Operator)를 통해 구현된다.
move semantics은 기존 객체에 있는 모든 멤버 변수의 주소를 이동하는 객체에게 넘겨주고 기존 객체가 가지고 있는 주소값을 지우기 때문에 소유권을 이전한다고 표현한다.
아래 코드는 Cat 클래스를 만들고 복사/이동 생성자와 복사/이동 대입 연산자를 구현하여 상황에 맞게 Cat 객체를 복사하거나 이동하며 사용하는 예시이다.
#include <iostream>
#include <string>
class Cat {
public:
// 기본 생상자(default constructor)
Cat() = default;
// 기본 소멸자(default destructor)
~Cat() = default;
// 커스텀 생성자(custom constructor)
Cat(std::string name) : name_{std::move(name)} {}
// 복사 생성자(copy constructor)
Cat(const Cat& other) : name_{other.name_} {}
// 이동 생성자(move constructor)
Cat(Cat&& other) noexcept : name_{std::move(other.name_)} {}
// 복사 대입 연산자(copy assign operator)
Cat& operator=(const Cat& other) {
// 같은 객체의 대입을 방지
if(&other == this) {
return *this;
}
name_ = other.name_;
return *this;
}
// 이동 대입 연산자(move assign operator)
Cat& operator=(Cat&& other) noexcept {
// 같은 객체의 대입을 방지
if(&other == this) {
return *this;
}
name_ = std::move(other.name_);
return *this;
}
private:
std::string name_;
};
int main() {
Cat cat1{"cat1"}; // 커스텀 생성자
Cat cat2{"cat2"}; // 커스텀 생성자
Cat cat3(cat1); // 복사 생성자 호출, cat1 객체의 멤버 변수를 cat3 객체의 멤버 변수가 복사
Cat cat4(std::move(cat1)); // 이동 생성자 호출, cat1 객체의 멤버 변수가 cat4 객체의 멤버 변수로 이동
cat3 = cat2; // 복사 대입 연산자 호출, cat2 객체의 멤버 변수를 cat3 객체의 멤버 변수가 복사
cat4 = std::move(cat2); // 이동 대입 연산자 호출, cat2 객체의 멤버 변수가 cat4 객체의 멤버 변수로 이동
// 임시 객체(rvalue)를 인자로 넣어줬으니 이동 생성자가 호출될 것 같지만 커스텀 생성자가 호출됨
// 컴파일러가 최적화를 통해 바로 커스텀 생성자를 호출해줌 -> 복사 생략(copy elision)
Cat cat5(Cat{"cat5"});
return 0;
}
std::move
std::move
는 lvalue를 rvalue로 캐스팅(타입 변환)하는 함수이다.
std::move
는 말처럼 값을 이동시키는 것이 아니라 단순히 타입을 변환하는 함수이다.
하지만 std::move
를 argument로 받는 함수는 이를 rvalue로 인식하여 특정 목적에 맞게 사용할 수 있다. (상황에 따라 lvalue 객체를 복사 생성/대입이 아닌 이동 생성/대입으로 구현할 수 있다)
// std::string에는 이동 생성, 이동 대입 연산이 구현되어 있음
std::string a = "hello";
std::cout << a << std::endl; // hello
std::string b = std::move(a); // 이동 생성자 호출
std::cout << a << std::endl; // 공백 (데이터가 b로 이동)
std::cout << b << std::endl; // hello
std::string c;
c = std::move(b); // 이동 대입 연산자 호출
std::cout << b << std::endl; // 공백 (데이터가 c로 이동)
std::cout << c << std::endl; // hello
std::forward
std::forward
는 lvalue reference를 lvalue로 rvalue reference를 rvalue로 캐스팅(타입 변환)하는 함수이다.
lvalue reference는 별다른 처리를 할 필요가 없지만 rvalue reference는 lvalue로 취급받기 때문에 다시 rvalue로 캐스팅(타입 변환)이 필요하다.
std::forward
는 인자에 lvalue와 rvalue가 둘 다 들어올 수 있을 상황에 유용하게 사용한다.
template
의 universal reference와 함께 자주 쓰인다.
template <class T>
void wrapper(T&& param) {
foo(std::forward<T>(param));
}