Modern C++?
C++11 부터 새롭게 나온 C++의 문법을 뜻한다.
C++11 전/후로 많은 차이가 있었기에 이렇게 따로 이름을 붙인것 같다.
auto
컴파일 단계에서 대입된 데이터를 기반으로 타입을 지정해준다.
"형식 연역" 이라고 하며 영어로 "type deduction" 이라고 한다.
주의점 :
auto 는 const 키워드와 &연산자를 무시하기에 이에 유의 해야한다.
1. const 키워드 무시
// const 무시
const int a = 1;
auto c = a;
c = 2;
2. & 연산자 무시 :
// refer 무시
int b = 1;
int& refer = b;
auto d = refer;
b = 2;
std::cout << b << " " << refer << " " << d << "\n"; // 2 2 1
d = 3;
std::cout << b << " " << refer << " " << d << "\n"; // 2 2 3
참조 연산자가 붙은 변수는 원본과 같은 주소값을 가져야 하지만, auto 변수의 경우 원본값이 참조자임에도 불구하고 연역된 타입을 확인해보면 참조 연산자가 떨어져있다.
주소값도 혼자 다름을 확인할 수 있다.
참조자가 붙은 변수의 경우 다른 변수의 값을 바꿔도 같은 주소지를 가리키기에 값이 다 같이 변해야하지만, auto 타입의 변수는 주소값이 다르기에 당연히 값의 변경에대한 영향을 받지 않음을 확인할 수 있다.
중괄호 초기화
int main()
{
int a = 0;
int b(1);
int c{ 2 };
std::cout << a << " " << b << " " << c << std::endl; // 0 1 2
return 0;
}
변수를 초기화 하는 방법은 위와 같이 다양하다.
C++11 전까지 중괄호를 이용한 초기화는 배열(array) 에서만 사용 가능했다.
int arr[3] = { 1,2,3 };
std::vector와 같은 STL 컨테이너를 중괄호를 이용해 초기화 할 수 있는 기능은 C++11 이후부터 가능해졌다.
장점 :
1. 초기화 일치화
기존엔 배열에서만 중괄호를 이용해 초기화를 할 수 있었으나, 다른 컨테이너들에서도 중괄호를 이용한 초기화가 가능해짐으로 일관성이 생겼으며 이로인해 사용하기 편해졌다.
2. 축소변환 방지
암묵적인 축소 변환을 방지해준다.
int 보다 double의 값 범위가 더 넓은데 "축소 변환" 에러가 발생하는 이유는...
실수는 일종의 근사치(지수부 + 로그부) 개념으로 정수와는 방식이 다르기에 정수 -> 실수 변환이 완벽히 이루어지지 않는다. 때문에 int -> double 변환시 손실이 생기지 않는다고 말할 수 없다.
3. 실수 방지
class Knight
{
private:
static int cnt;
public:
Knight()
{
std::cout << "Knight num " << cnt++ << std::endl;
}
};
int Knight::cnt = 0;
int main()
{
Knight k1;
Knight k2(); // Knight 타입의 값을 반환하는 함수 k2의 선언
Knight k3{};
}
위의 예시 코드와 같이 직접 만들어준 클래스에 대한 객체를 생성할 때
main 함수의 두번째 경우와 같이 Knight 타입 객체를 생성하려고 했으나 문법 실수로 함수를 선언해버린경우가 생길 수 있다.
이때, 소괄호가 아니라 중괄호를 사용하면 실수를 하더라도 정상적으로 객체를 생성해준다.
단점 :
class Knight
{
public:
Knight(int a)
{
std::cout << "Func_1" << std::endl;
}
Knight(std::initializer_list<int> i)
{
std::cout << "Func_2" << std::endl;
}
};
int main()
{
Knight k1(1); // Func_1
Knight k2{ 1 }; // Func_2
}
중괄호 초기화는 인자로 "initializer_list<T>" 타입의 객체를 받는다.
해당 인자를 받아주는 생성자가 존재할시 다른 생성자들보다 우선순위를 가져 의도하지 않은 생성자를 호출할 수도 있다.
직접 생성한 클래스의 경우 인자로 std::initializer_list<T> 타입을 받는 생성자가 없다면 중괄호 초기화를 하더라도 큰 문제는 없다. 허나 누군가 추가하는순간... 큰일난다...
이와 비슷한 경우로 std::vector 를 초기화 할때 중괄호를 사용하는 경우와 소괄호를 사용하는 경우에 차이가 생긴다.
std::vector<int> v1(1, 2); // v1 = {2} -> 2로 채워진 1개의 인자
std::vector<int> v2{ 1,2 }; // v2 = {1, 2} -> 인자 2개 1, 2
스타일 :
1. ( ) : 소괄호를 이용한 초기화가 메인, 거부감이 덜하다, vector 와 같은 특별한 케이스에만 사용
2. { } : 중괄호를 이용한 초기화, 초기화 문법의 일치화, 축소변환의 방지
nullptr
Modern C++ 문법임을 잊을만큼 자주 사용되며 일반화가 된 문법이다.
nullptr이 나오기 전엔 0 또는 NULL 을 사용했다.
nullptr 도 0을 표현하긴 한다.
옛 방식의 단점 :
1. 오작동
0 과 NULL 은 의미적으로 정수와 같은 값을 뜻하는 느낌이 강하기에 값만 보아선 포인터를 뜻함을 알아차리기 어렵다.
class Knight
{
public:
void Fun(int a)
{
std::cout << "Func(int)\n";
}
void Fun(void* ptr)
{
std::cout << "Func(ptr)\n";
}
};
int main()
{
Knight k;
k.Fun(NULL); // Func(int)
k.Fun(0); // Func(int)
k.Fun(nullptr); // Func(ptr)
return 0;
}
오버로딩하여 포인터와 정수값을 받아주는 함수가 둘다 존재하는 경우 인자로 NULL 또는 0을 넣은 경우 포인터를 의미하는 값이었다 한들, 정수값을 받아주는 함수가 호출되어버린다. -> nullptr 사용시 해결 가능
2. 가독성 저하
auto 타입으로 값을 받는 변수를 조건문에서 0 또는 NULL 과 비교연산을 하면 해당 값이 포인터를 뜻하는지 정수값을 뜻하는지 코드만 보아선 알아차리기 힘들다.
// 가독성
// auto에 의해 추론이 되기에 당장 코드만 봐선
// k 가 ptr 인지 정수인지 알기 힘들다.
auto k = FindKnight();
if (k == NULL)
{
//
}
nullptr 구현 :
일반 숫자는 아니고 객체에 가깝다고 생각하면 된다.
const // 선언과 동시에 객체를 만드는 경우 const 붙이는 방법
class /*NullPtr*/
{
public:
// 어떤 타입의 포인터와도 호환이 된다.
template<typename T>
operator T* () const
{
return 0;
}
// 어떤 타입의 멤버 포인터와도 치환이 가능하다.
template<typename C, typename T>
operator T C::* () const
{
return 0;
}
private:
// NullPtr의 주소값을 가져오려고 시도해도
// &연산자가 private 으로 선언되어 있기에 사용할 수 없다.
void operator& () const; // 주소값 &을 막는다.
//void operator& () const = delete; // 좀 더 현대적인 문법
} _nullptr; // 선언과 동시에 실제 객체를 만들어주는 문법
// 위의 방식으로 객체를 만들면 클래스의 이름도 필요 없게된다.
//const NullPtr _nullptr;
using
typedef 대신 사용할 수 있는 문법의 등장
일반적으로 typedef 은 사용하는 타입의 이름이 너무 길어 가독성을 해치는 경우 사용해줬다.
typedef std::vector<int>::iterator vecIt;
using vecIt2 = std::vector<int>::iterator;
장점 :
1. 높은 직관성
// 직관성
typedef void (*MyFunc)(); // typedef
using MyFunc2 = void(*); // using
2. 템플릿과의 조합
typedef 은 템플릿과 맞지 않는 형태이다. -> 이유 : using 과 동일하게 입력시 문법 오류가 발생한다.
// 템플릿
template <typename T>
using List1 = std::vector<T>;
// 문법 오류 발생
template <typename T>
typedef std::vector<T> List2;
만약 typedef으로 사용하고자 한다면 아래와 같이 작성해줘야한다. -> 선언도 어렵고 사용법도 귀찮다.
template <typename T>
struct List2
{
typedef std::vector<T> myType;
};
int main()
{
// using
List1<int> li;
li.push_back(1);
// typedef
List2<int>::myType li2;
li2.push_back(1);
return 0;
}
enum class
"scoped enum" 이라고 부르기도 한다.
enum 과 enum class 는 서로 장단점이 있다.
장점 :
1. 이름공간 관리
enum은 본인에게 정의된 값들과 동일 스코프를 가진다는 단점이 있었다.
이러한 단점 때문에 변수명 중복을 피라기위해 각 값들의 앞에 키워들를 붙여주는 등의 작업을 해줘야 했지만 enum class 의 경우 이러한 단점을 해결 할 수 있다.
enum 과 달리 enum class 는 본인에게 정의된 값들이 enum class 하위 스코프에 정의 되기에 값의 중복등에 대한 추가적인 작업을 해주지 않아도 된다.
enum Enum
{
E_One,
E_Two,
E_Three,
};
enum class EnumClass
{
one,
two,
three,
};
2. 암묵적 변환 금지
임의로 정수타입으로
해당 특징은 무조건 좋다고만 볼 수는 없다.
숫자에 의미만 부여하고 실제로는 숫자 값으로 사용하고자 한 경우엔 enum 이 더 효율적이다.
delete (삭제된 함수)
생성해주지 않았지만 컴파일러에 의해 자동으로 생성되는 기능(함수)들이 있다. ex) 기본 생성자, 복사 생성자, etc ...
이들을 막을 수 있는 방법에는...
1. private 로 선언하기
class Knight
{
// case1 : private 로 대입 연산자 호출 못하도록 막기
// friend 키워드에 뚫린다.
private:
void operator=(const Knight& k)
{
this->_hp = k._hp;
}
private:
int _hp = 100;
};
해당 방법은 외부에서 함수를 호출할 수 없도록 해준다.
하지만 friiend로 지정된 클래스에선 private 함수에도 접근할 수 있다는 단점이 있다.
2. private 로 선언하고 정의하지 않기
class Knight
{
// case2 : 정의를 하지 않았기에 링크에러 발생한다. -> 즉, 사용 못한다.
// 링크 에러 메세지가 등장하기에 찝찝하다.
private:
void operator=(const Knight& k)
private:
int _hp = 100;
};
정의를 해주지 않았기에 해당 함수에 접근을 하더라도 정의부가 없다는 링크에러가 발생한다.
정상적인 경고문이 아니라 에러가 발생한다는 점에서 찝찝하다.
3. 선언부 뒤에 "= delete" 붙이기
class Knight
{
public:
void operator=(const Knight& k) = delete;
private:
int _hp = 100;
};
가장 현대적인 방법으로 함수의 선언부 뒤에 "= delete" 를 붙이면 해당 함수를 생성하지 않겠다는 의미가 되어 만약 호출을 한다면 출력창에 에러가 아닌 경고문이 뜬다.
override, final
상속과 가상함수에 연관이 있는 키워드들이다.
상속관계에 있는 경우 virtual 키워드가 있는 함수는 자식 객체에서 재정의(override) 할 수 있다.
class Player
{
public:
//void Attack()
virtual void Attack()
{
std::cout << "Player!!" << std::endl;
}
};
class Knight : public Player
{
public:
// 재정의 override
//void Attack() // override 했음을 알려주기 위해 virtual, override 키워드 붙여주는게 좋다.
virtual void Attack() override // 정석
//virtual void Attack() const // virtual 키워드에 속으면 안된다. 여기서 새롭게 정의된 함수이다.
{
std::cout << "Knight!!" << std::endl;
}
};
virtual 키워드를 부모 객체 함수에서 한번 붙였다면 자식 객체에선 굳이 virtual 키워드를 붙이지 않아도 재정의를 할 수 있다.
하지만, 코드를 읽을 때 자식 객체만 봐서는 해당 함수가 부모 객체의 함수를 재정의 한 것인지 자식 클래스에서 새롭게 선언된 함수인지 구분할 수 없다.
때문에 가독성을 위해서 반드시 자식 객체에서 재정의한 함수에는 virtual, override 키워드를 붙여주도록 하자.
final 키워드는 더이상 자식 객체에서 재정의 하지 못하도록 하겠다는 키워드이다. override 와 같이 함수의 뒤에 붙여주면 된다.
오른값(rvalue) 참조
게임 컨텐츠 생성에 활용할 일은 적으나 필수적으로 알아야 하는 문법
Modern C++의 핵심, 꽃 이라고 한다.
정의 :
int a = 3;
// a : 왼값에 해당한다.
// 해당 줄을 지나서도 a 는 변수로서 지속적으로 사용 가능하다.
// 3 : 오른값에 해당한다.
1. 왼값 lvalue :
단일식을 넘어서 계속 지속되는 개체...?
2. 오른값 rvalue :
왼값을 제외한 모든 것들... (임시값, 열거형, 람다, etc...)
실습 :
class Pet
{
};
class Knight
{
public:
Knight()
{
}
~Knight()
{
if (nullptr != _pet)
delete _pet;
}
public:
// 복사 생성자 (복사1)
Knight(const Knight& knight)
{
std::cout << "const Knight&" << std::endl;
}
// 복사 대입 연산자 (복사2)
void operator=(const Knight& knight)
{
std::cout << "operator=(const Knight&)" << std::endl;
// 얕은 복사
//_hp = knight._hp;
//_pet = knight._pet; // 상대방 기사의 펫을 공유?하게된다.
// 깊은 복사
// 필요한 정보만 빼서 새로운 객체를 만들어 넣어준다.
if (knight._pet)
_pet = new Pet(*knight._pet);
}
public:
int _hp = 100;
Pet* _pet = nullptr;
};
Knight 클래스의 내부에 정의되어 있는 복사 대입 연산자를 보면 인자로 들어온 Knight 객체의 원본을 해치지 않으면서 해당 인자를 기반으로 객체를 생성한다. (깊은 복사)
// "이동" 대입 연산자
// 인자로 들어온 값의 원본은 이동대상이다.
// 즉, 원본을 유지할 필요가 없다.
void operator=(Knight&& knight) noexcept // noexcept : 에러줄 사라진다.
{
std::cout << "operator=(Knight&&)" << std::endl;
_hp = knight._hp;
// &&연산자가 붙어있다는 것은 knight을 해당 함수를 벗어나면 사용하지 않을것이라는 힌트를 준것
// 인자로 들어온 knight 의 펫을 가져버리면 된다.
// 즉, 굳이 깊은 복사를 해주지 않아도 된다.
_pet = knight._pet;
// 소유권이 넘어갔기에 펫이 없는걸로 표기해준다.
knight._pet = nullptr;
}
&& 연산자는 "오른값 참조 연산자" 라고 부른다.
오른값 참조 연산자를 붙이면 해당 인자의 원본을 유지할 필요가 없음을 알려준다.
때문에 굳이 깊은 복사를 해주지 않아도 된다.
std::move() :
해당 함수의 인자로 들어온 왼값을 오른값으로 바꿔서 반환해준다.
이동 대입 연산자에 왼값을 넣을때 static_cast<T&&> 대신에 std::move() 함수를 사용하면 편하다.
Knight k1;
k1._hp = 1000;
k1._pet = new Pet();
Knight k2;
// 원본은 날려도 된다는 힌트를 주는 쪽에 가깝다.
// 복사를 하지 않고도 이동을 할 수 있다는 큰 장점이 있다.
k2 = static_cast<Knight&&>(k1);
// 동일한 의미이다.
// 오른값 참조로 k1을 케스팅한것과 같은 의미
k2 = std::move(k1);
위 연산을 마치면 k1의 _pet은 nullptr이 되고, k2는 기존에 k1 이 가지고 있던 pet을 가지게된다.
이동 대입 연산자의 인자로 들어왔다는 의미가 원본은 더이상 사용하지 않을것이니 원본의 값을 마음대로 사용해도 된다는 의미이다.
std::move() 함수는 단순히 왼값을 오른값으로 바꿔주는 역할만을 하는데 함수 이름때문에 값을 옮겨주는 것처럼 느껴진다. 오해하지 않도록 주의하자.
Tip :
std::unique_ptr<Knight> uptr = std::make_unique<Knight>();
//std::unique_ptr<Knight> uptr2 = uptr; // 복사 대입 연산자를 삭제해놔서 안된다.
std::unique_ptr<Knight> uptr2 = std::move(uptr); // 이동 대입 연산자, uptr2가 권한을 넘겨받는다.
스마트 포인터에서 unique_ptr의 권한을 넘겨받는 방법으로 "아동 대입 연산자"를 사용할 수 있다.
전달 참조
기존의 이름은 "보편 참조 (universal reference)" 였으나,
C++17로 넘어오면서 "전달 참조(forwarding reference)"로 이름이 바뀌었다.
// 뭘까?
auto&& k2 = k1; // lvalue
auto&& k3 = std::move(k1); // rvalue
// 공통점 : 형식 연역 (type deduction)
// 오른값을 넣어주면 오른값, 왼값을 넣어주면 왼값으로 작동한다.
오른값 참조와 유사해서 헷갈린다. 사용할 일은 많지 않지만 문법을 모르면 전혀 이해하지 못하기에 알고는 있어야한다.
타입 연역(추측, guess)를 하는 캐스팅에서 많이 사용한다.
&& 연산자가 무조건 오른갑 참조 연산자인건 아니다.
class Knight
{
public:
Knight() { std::cout << "기본 생성장\n"; }
Knight(const Knight&) { std::cout << "복사 생성자\n"; }
Knight(Knight&&) noexcept { std::cout << "이동 생성자\n"; }
};
void Test_Copy(Knight k)
{
}
template <typename T>
void Test_ForwardingRef(T&& param) // 전달 참조
{
// 왼값을 넣어주면 왼값으로 동작하고
// 오른 값을 넣어주면 오른값으로 동작한다.
// 오른값 참조값으로 param이 들어왔지만 재사용이 되기에 왼값으로 인식된다.
Test_Copy(param);
// 오른값으로 사용하기 위해선 다시 std::move() 로 형식을 변환해줘야한다
Test_Copy(std::move(param));
// 인자가 오른값 참조 타입이 아닌경우 내부에서 위와 같이 std::move 를 사용하게되면
// 원본을 홰손 할 수도 있기에 함부로 사용하면 안된다.
// 분기를 만들어줘야한다.
// std::forward
// 왼값 참조라면 복사 생성자 호출, 오른값 참조라면 이동 생성자 호출할 수 있도록 해준다.
//Test_Copy(std::forward(param)); // 문제 발생하여 아래와 같이 T 타입을 전달
Test_Copy(std::forward<T>(param));
}
template <typename T>
void Test_ForwardingRef2(const T&& param) // 오른값 참조
{
//const 가 붙는 순간 오른값 참조가 되어버린다.
}
전달 참조를 만든 이유 :
경우의 수가 많아지는 함수의 경우 오버라이딩을 통해 다양한 경우의 수를 모두 만족시켜주는 함수를 여러개 만들어줘야한다.
이때, 전달 참조 연산자를 사용하면 만들어줘야하는 함수의 개수를 획기적으로 줄일 수 있다.
2탄
Modern C++ 추가 내용 → https://coder-qussong.tistory.com/20
Modern C++_2탄
오른값 참조 2 오른값 참조 타입을 인자로 받는 Test_RValueRef() 함수가 있다고 가정했을 때, k4의 경우 왼값이기에 인자로 들어갈 수없는건 이해가 간다. 하지만, k5의 경우 std::move() 함수를 사용하여
coder-qussong.tistory.com
(Tistroy 포스팅 글자수가 일정양을 넘어가면 렉이 걸려 분할함)
'DirectX11 > Rookiss' 카테고리의 다른 글
함수 객체 (Function Object) (0) | 2024.03.16 |
---|---|
함수 포인터 (0) | 2024.03.16 |
비트 연산 (0) | 2024.03.16 |
Modern C++_2 (0) | 2024.03.16 |
[게임 프로그래머 도약반] DirectX11 입문_강의노트 (0) | 2024.03.12 |