언리얼 엔진은 크로스 플랫폼 지원을 기본으로 하는 프레임워크다.
그래서 최신 C++ 문법을 지원할 때에 모든 플랫폼에서 안정적으로 동작할 수 있도록 현재 C++11의 일부 문법만을 지원하고 있다. (4.21버전 기준)
(그 외에도 멀티플랫폼 안정성을 위해 STL이 아닌 자체 컨테이너(ex:TArray TMap TSet)나 스트링 처리를 지원함- 참고)
언리얼이 지원하는 C++11 문법은 다음과 같다.
1. static_assert
일반적으로 발생 가능하다고 기대되는 경우에 대한 예외처리는 try catch나 if문을 사용해 걸러낸다.
그에 반해 assert는 절대 발생하면 안되는 상태를 체크할 때 사용한다. (발생하는 상황 자체가 이상한 경우)
1 2 3 4 5 6 7 8 9 10 11 |
#include <cassert> void foo(int count) { assert(count > 0); for(int i = 0; i < count; ++i { //동작 } } |
cs |
위와 같은 상황에서 count는 양수로 기대되는 값으로, 만약 음수가 들어온다면 뭔가 잘못된 경우이다. 그래서 assert문을 사용해 바로 assertion fail 런타임 에러를 내도록 만든다.
단 assert문은 release 모드에서 성능을 위해 배제되며 debug 빌드 모드(전처리기로 NDEBUG가 정의된 경우)에서만 작동한다.
일반적인 assert문은 런타임에 조건을 체크하고 런타임 에러를 발생시키지만, C++11에 추가된 static_assert는 컴파일 타임에 판단할 수 있는 조건에 대해 사용할 수 있다.
1 2 3 4 5 |
const int m = 10; const int a = 5; const int f = 50; static_assert(f == m * a, "f != m * a"); |
cs |
위의 예제는 살짝 억지스럽긴 하지만 여튼 이런식으로 사용할 수 있다
2. override와 final 키워드
원래 기본 C++에서는 상속에 대한 제한 규칙이 별로 없다.
이게 꽤 혼란스럽고 의도치 않은 동작을 만들 수 있기 때문에, C++11에서 override와 final 키워드가 추가되었다.
override는 명시적으로 base 클래스의 virtual 함수를 오버라이드했다고 알리는 키워드이다.
override 키워드를 붙여주면 컴파일러가 컴파일 타임에 base 클래스의 virtual 함수를 override 가능한지 검사해서 에러 메세지를 표시해준다.
1 2 3 4 5 6 7 8 9 10 11 12 |
class A { virtual void foo(int param); }; class B : A { void foo(int param) override; // OK void foo(float param) override; // Error void foo() const override; // Error void bar() override; // Error }; Colored by Color Scripter |
cs |
프로그래머가 함수를 오버라이드할 때 실수하기 쉬운 많은 점들을 override 키워드를 통해 미리 차단할 수 있다.
이는 Modern Effective C++ 12장에서도 확인할 수 있다 (12.오버라이딩 함수에 override를 선언하자)
final 키워드는 C#의 sealed 처럼 하위 클래스에서의 오버라이드를 차단하는 역할을 한다.
1 2 3 4 5 6 7 8 9 |
class A { virtual void foo() final; }; class B : A { void foo(); // Error }; |
cs |
이 두 키워드는 언리얼에서도 강력하게 사용을 권장하고 있다.
3. nullptr
현대 C++에서 null 포인터를 0 혹은 NULL로 표현하는 것은 권장 사항이 아니다. (effective modern C++ 8장 0과 NULL보다 nullptr를 선호하라)
null pointer를 NULL이나 0으로 표현하면, int 타입인지 포인터 타입인지 판단하기가 모호하기 때문이다.
null pointer는 std::nullptr_t라는 타입을 가지며 이 타입은 모든 타입의 포인터 타입으로 암시적 형변환이 됨으로써 모든 타입의 포인터와 같이 동작하게 된다.
언리얼에서는 nullptr 사용을 강력히 권장한다.
4. auto
auto는 C++11에서 C#의 var처럼 변수 초기화시에 자동 타입 추론을 위해 도입된 키워드이다.
auto 키워드를 사용하면 컴파일러는 컴파일 타임에 해당 변수의 타입을 추론한다. (https://stackoverflow.com/questions/19618759/c-11-auto-compile-time-or-runtime)
다시말해, auto 키워드는 컴파일 타임에 타입을 추론할 수 있는 경우에만 사용할 수 있다. 예를 들어, 다음과 같은 상황에는 사용할 수 없다.
1 2 3 4 |
void PrintSum(auto x, auto y) { cout << x + y; } |
cs |
이런 경우 보통 auto가 template처럼 동작하기를 기대하지만, 전혀 그렇지 않다. auto는 런타임에 다양한 타입을 가질 수 있는 특수한 마법의 타입이 아니며, 컴파일러에게 변수의 타입의 추론을 맡기는 정도의 역할을 하는 키워드이다.
하지만 언리얼에서는 auto의 사용 범위를 몇가지 특수한 상황으로 제한하고 있다.
그렇기 때문에 우선 일반적인 변수의 초기화 시에는 auto를 사용하지 않아야 한다.
1 | auto a = 1; | cs |
(이렇게 막 쓰지는 말라는 뜻이다)
또한 C++14에서 추가된 함수 리턴 타입 추론 기능이 있는데 이것 또한 사용해서는 안된다
1 2 3 4 |
auto Add(int x, int y) { return x + y; } |
cs |
써도 되는 경우는 다음과 같다
(1) 변수에 람다를 바인딩해야 하는 경우 - 람다 유형은 표현 불가능
1 2 3 |
auto lambda = []() { }; |
cs |
람다는 std::function에 대입 가능하지만 그냥 auto를 사용하는게 간편하다.
(2) 이터레이터 변수의 경우 - 너무 장황한 타입 이름이 가독성에 악영향을 끼침
다들 알다시피 이터레이터 타입을 전부 다 쓰면 너무 복잡하다.
1 2 3 4 5 6 7 8 9 10 11 |
for (std::vector<int>::iterator itr = vect.begin(); itr != vect.end; ++itr) { } for (auto itr = vect.begin(); itr != vect.end(); ++itr) { } for (auto itr : vect) { } Colored by Color Scripter |
cs |
auto로 가독성을 향상시킬 수 있다.
(3) 템플릿 코드에서 표현식의 유형을 쉽게 식별할 수 없는 경우
이건 문서에 설명이 빈약해서 정확히 어떤 경우인지 잘 모르겠지만, 템플릿 타입이 중첩되어 타입 가독성이 떨어졌을 경우 사용할 수 있다고 말하는 듯 하다.
5. 범위 기반 for문
가독성을 위해 사용하기를 권장하고 있다.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
TMap<FString, int32> MyMap; // Old style for (auto It = MyMap.CreateIterator(); It; ++It) { UE_LOG(LogCategory, Log, TEXT("Key: %s, Value: %d"), It.Key(), *It.Value()); } // New style for (TPair<FString, int32>& Kvp : MyMap) { UE_LOG(LogCategory, Log, TEXT("Key: %s, Value: %d"), Kvp.Key, *Kvp.Value); } Colored by Color Scripter |
cs |
6. 람다 및 무명 함수
람다란 이름이 없는 함수를 말하며 다음과 같은 포맷을 가진다.
[ captures ] ( params ) -> ret { body }
람다와 무명 함수에 대해 가장 잘 설명한 포스팅이 있어 가져옴
https://blog.koriel.kr/modern-cpp-lambdayi-teugjinggwa-sayongbeob/
단, 언리얼에서는 람다를 2-3 줄 이내에 사용하기를 권장한다. 하지만 자동 캡처는 지양하고 수동 캡처를 사용하고, 명시적 반환형(Sort처럼 너무 명확한 경우 제외)을 사용하기를 권하고 있다.
7. 강유형 ENUM (Enum class)
기존의 enum은 int로 너무 쉽게 형변환되어서 서로 다른 enum 타입들을 비교할 때 문제가 많았다.
C++ 11의 enum class는 int로의 암시적 형변환을 막고 scope를 제한해 명시적으로 어떤 enum의 이름인지 지정해야 한다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
// Old enum UENUM() namespace EThing { enum Type { Thing1, Thing2 }; } // New enum UENUM() enum class EThing : uint8 { Thing1, Thing2 }; |
cs |
언리얼은 C++11에서 나온 강한 형식 지정을 통해 이넘의 다양한 부작용을 없앤 이넘 클래스를 사용하기를 권장한다.
1 2 3 4 5 6 7 |
// Old property UPROPERTY() TEnumAsByte<EThing::Type> MyProperty; // New property UPROPERTY() EThing MyProperty; |
cs |
UPROPERTY로 지정하는 것 또한 가능하다.
만약 플래그로 지정하고 싶다면 ENUM_CLASS_FLAGS 매크로를 사용하면 된다.
1 2 3 4 5 6 7 8 9 |
enum class EFlags { None = 0x00, Flag1 = 0x01, Flag2 = 0x02, Flag3 = 0x04 }; ENUM_CLASS_FLAGS(EFlags) |
cs |
그러면 자동으로 비트관련 연산자를 자동으로 정의해준다. 단 주의할 점은 0으로 설정된 None을 꼭 포함해야한다. 그래야 비트 연산시 의도치 않은 결과가 발생하지 않는다
1 2 3 4 5 |
// Old if (Flags & EFlags::Flag1) // New if ((Flags & EFlags::Flag1) != EFlags::None) |
cs |
8. move semantics
move semantics는 l value를 r value로 리턴해줌으로써 r value reference를 가능하게 하고, 그를 통해 다양한 상황에서 복사가 발생하는 것을 막아준다.
https://en.cppreference.com/w/cpp/utility/move
Effective modern C++ 10에서는 std::move와 std::forward를 자세히 소개하고 어떤 경우에 사용하는 것이 좋은지 알려준다.
항목 23: std::move와 std::forward를 숙지하라
항목 24: 보편 참조와 오른값 참조를 구별하라
항목 25: 오른값 참조에는 std::move를, 보편 참조에는 std::forward를 사용하라
항목 29: 이동 연산이 존재하지 않고, 저렴하지 않고, 적용되지 않는다고 가정하라
언리얼4에서는 모든 주요 컨테이너 유형, TArray, TMap, TSet, FString 에는 move 생성자와 move 할당 연산자가 있고, std::move를 MoveTemp라는 함수를 통해 제공하고 있다고 한다.
https://api.unrealengine.com/INT/API/Runtime/Core/Templates/MoveTemp/index.html
9. 디폴트 멤버 이니셜라이저
C++11에서 도입된, 클래스 멤버를 선언과 동시에 초기화할 수 있는 기능이다.
생성자에서도 초기화하고, 멤버 선언에서도 초기화할 시 생성자에서의 초기화가 우선시된다.
http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2008/n2628.html
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
UCLASS() class UTeaOptions : public UObject { GENERATED_BODY() public: UPROPERTY() int32 MaximumNumberOfCupsPerDay = 10; UPROPERTY() float CupWidth = 11.5f; UPROPERTY() FString TeaType = TEXT("Earl Grey"); UPROPERTY() EDrinkingStyle DrinkingStyle = EDrinkingStyle::PinkyExtended; }; Colored by Color Scripter |
cs |
멤버 이니셜라이저는 장단점이 있는데 장점으로는,
(1) 여러 생성자에 이니셜라이저를 복제하지 않아도 됨
(2) 초기화 순서와 선언 순서가 동일하게 보장됨
(3) 멤버 타입, 프로퍼티 플래그, 기본값을 한번에 볼 수 있어 가독성과 유지보수에 유리
단점으로는
(1) 헤더에 기록되므로 기본 값을 변경할 시 모든 종속 파일을 리빌드 해야함
(2) 헤더는 엔진 패치 릴리즈에서 변경할 수 없어 가능한 픽스 종류가 제한될 수 있음
(3) 베이스 클래스, UObject 서브오브젝트, 앞서 선언한(forward-declared) 유형으로의 포인터, 컨스트럭터 인수에서 추론해 낸 값, 여러 단계에 걸쳐 초기화된 멤버는 이러한 방식으로 초기화할 수 없음
(4) 일부는 헤더에서, 일부는 생성자에서 초기화할 경우 가독성과 유지보수에 좋지 않음.
언리얼에서는 적절히 판단해서 사용하라고 되어있다.
'game dev' 카테고리의 다른 글
마인크래프트 역기획서! : 레드스톤과 논리 회로 (0) | 2021.07.26 |
---|
댓글