본문 바로가기
언리얼엔진

언리얼 엔진 5 코딩 표준 (최신 C++ 언어 문법 및 서드 파티 코드)

by 대니스 2025. 2. 19.

최신 C++ 언어 문법

언리얼 엔진은 다수의 C++ 컴파일러로 대규모 포팅(소프트웨어를 다른 플랫폼에서도 동작할 수 있도록 변환하는 것)이 가능하도록 만들어졌다.

언리얼 엔진은 기본적으로 C++20 언어 버전으로 컴파일하며, 빌드 시 요구하는 최소 언어 버전은 C++20이다. 언리얼 엔진은 최신 컴파일러 전반에서 잘 지원되는 다수의 최신 언어 기능을 사용한다.경우에 따라 Preprocessor(전처리기) 조건문에 이러한 기능의 사용을 래핑한다 그러나 가끔은 portability나 다른 이유로 인해 특정 언어 기능 전체를 사용하지 않기로 결정하는 경우도 있습니다

아래에 지원되는 최신 C++ 컴파일러 기능으로 명시된 것 이외의 컴파일러 전용 언어 기능에 대해서는, 프리프로세서 매크로나 조건문에 래핑한 경우가 아니라면 사용하지 말아야 하며, 래핑했다 하더라도 신중하게 사용해야 한다

 

스태틱 어서트

static_assert 키워드는 컴파일 시간 어서트가 필요한 경우 사용할 수 있다

 

Override 및 Final

override 및 final 키워드는 사용할 수 있을 뿐만 아니라, 사용을 강력히 권한다. 빠진 부분이 있다면 이는 수정될 예정이라고 한다

 

Nullptr

nullptr은 모든 경우 C 스타일 NULL 매크로 대신 사용해야 한다

한 가지 예외로는 C++/CX 빌드(예: Xbox One)의 nullptr 사용이 있다. 이 경우 nullptr 사용은 사실 관리된 null 레퍼런스 타입이다. 타입이나 일부 템플릿 인스턴스화 컨텍스트를 제외하면 네이티브 C++의 nullptr 과 거의 호환되므로, 호환성을 위해서는 좀 더 일반적인 decltype(nullptr) 대신 TYPE_OF_NULLPTR 매크로를 사용해야 한다.

 

Auto

아래 몇 가지 예외를 제외하면 C++ 코드에서 auto 를 사용해서는 안 됩니다. 초기화하려는 타입은 항상 명시해 주어야 한다. 즉, 읽는 사람에게 타입이 명확하게 보여야 한다는 뜻이다. 이 규칙은 C#의 var 키워드 사용에도 적용된다.

또한 C++20의 구조체 바인딩(어떤 배열, STL 같은 컨테이너에서 멤버들을 쉽게 바인딩할 수 있도록 도와주는 문법) 기능도 실질적으로 variadic auto 이므로 사용해서는 안 된다.

 

auto 를 사용 가능한 경우는 다음과 같다

- 변수에 람다를 바인딩해야 하는 경우. 람다 타입은 코드로 표현할 수 없기 때문이다.

- 이터레이터 변수의 경우. 단, 이터레이터 타입이 매우 장황하여 가독성에 악영향을 미치는 경우에 한이다.

- 템플릿 코드에서 표현식의 타입을 쉽게 식별할 수 없는 경우. 고급 사용 사례다.

 

코드를 읽는 사람에게 타입을 명확하게 알리는 것은 매우 중요하다. 일부 IDE에서 타입을 추론할 수는 있지만, 이는 코드가 컴파일 가능 상태라는 가정하에서만 가능하다. merge/diff 툴 사용자나, GitHub와 같은 곳에서 개별 소스 파일을 독립적으로 확인하는 경우에도 도움이 되지 않는다.

auto 를 사용해도 괜찮다는 확신이 든다면, 항상 타입 이름과 마찬가지로 const , & 또는 * 를 정확히 사용해야 한다. 그래야만 auto 를 통해 원하는 추론 타입을 이끌어낼 수 있다.

 

변수 기반 For

코드의 가독성과 유지보수성 향상에 도움이 되므로 사용을 추천한다. 기존 TMap 이터레이터를 사용하는 코드를 이주할 때는, 기존 이터레이터 타입 메서드였던 Key() 및 Value() 함수가 이제 단순히 내재된 키 값 TPair 의 Key 및 Value 필드가 되었음에 유의한다.

TMap<FString, int32> MyMap;
    
    // 기존 스타일
    for (auto It = MyMap.CreateIterator(); It; ++It)
    {
        UE_LOG(LogCategory, Log, TEXT("Key: %s, Value: %d"), It.Key(), *It.Value());
    }
    
    // 새 스타일
    for (TPair<FString, int32>& Kvp : MyMap)
    {
        UE_LOG(LogCategory, Log, TEXT("Key: %s, Value: %d"), *Kvp.Key, Kvp.Value);
    }

 

몇몇 독립형 이터레이터 타입에서 범위로 대체한 것도 있다

// 기존 스타일
    for (TFieldIterator<UProperty> PropertyIt(InStruct, EFieldIteratorFlags::IncludeSuper); PropertyIt; ++PropertyIt)
    {
        UProperty* Property = *PropertyIt;
        UE_LOG(LogCategory, Log, TEXT("Property name: %s"), *Property->GetName());
    }
    
    // 새 스타일
    for (UProperty* Property : TFieldRange<UProperty>(InStruct, EFieldIteratorFlags::IncludeSuper))
    {
        UE_LOG(LogCategory, Log, TEXT("Property name: %s"), *Property->GetName());
    }

 

람다 및 익명 함수

람다는 자유롭게 사용할 수 있다. 람다를 최적으로 사용하려면 길이상 두 구문 정도가 되어야 한다. 특히 규모가 더 큰 표현식이나 구문의 일부로 사용될 때, 예를 들면 범용 알고리즘의 술부(predicate)에 사용될 때는 더욱 그렇다.

// 이름에 단어 'Hello'가 포함된 첫 번째 Thing을 검색합니다.
    
Thing* HelloThing = ArrayOfThings.FindByPredicate([](const Thing& Th){ return Th.GetName().Contains(TEXT("Hello")); });
    
    // 배열을 이름 역순으로 정렬합니다.    
Algo::Sort(ArrayOfThings, [](const Thing& Lhs, const Thing& Rhs){ return Lhs.GetName() > Rhs.GetName(); });|

스테이트풀 람다는 자주 사용하는 경향이 있는 함수 포인터에 할당할 수 없다는 점에 유의해야 한다. 사소하지 않은 람다는 일반 함수와 같은 방식으로 문서화해야 한다

 

캡처 및 반환 타입

자동 캡처(본문에서 사용하는 외부 변수에 대해 그 사용 방식을 변수명에 묵시적으로 정하거나 새 이름으로 명명하여 명시적으로 사용 방식을 정하는 것)보다는 명시적(explicit) 캡처를 사용해야 한다([&] 및 [=] ). 대규모 람다와 지연(deferred) 실행에 사용되는 경우 가독성, 유지보수성, 퍼포먼스 측면에서 특히 중요하다.

 

작성자의 의도를 선언하므로 코드 리뷰 과정에서 실수를 잡아낼 수 있다. 잘못된 캡처는 부정적인 결과를 낳을 수 있으며, 추후 코드 유지보수 과정에서 문제가 될 확률이 높다. 람다 캡처에 관해 추가적으로 유의할 점이 몇 가지 있다.

- 람다 실행이 지연된 경우 포인터 참조 캡처와 포인터 값 캡처가 때때로 허상 참조를 유발할 수 있다(this 포인터 포함).

- 값 캡처는 지연되지 않은 람다에 불필요한 사본을 만드는 경우 퍼포먼스상의 우려가 발생할 수 있다.

- 잘못 캡처된 UObject 포인터는 가비지 컬렉터에 보이지 않는다. [=] 가 람다에 모든 것의 별도 사본이 있다는 인상을 주기는 하지만, 자동 캡처는 멤버 변수가 참조된 경우 묵시적으로 this 를 캡처한다.

 

대규모 람다이거나 다른 함수 호출의 결과를 반환할 때는 명시적 반환 타입을 사용해야 한다. 다음과 같이 auto 키워드와 동일한 방식으로 고려해야 한다.

 

강- 타입 Enum

열거형(Enumerated, Enum) 클래스는 기존 네임스페이스 열거형인 일반 열거형 및 UENUM 을 대체한다. 예를 들어 다음과 같이 할 수 있다

	  // 기존 열거형
    UENUM()
    namespace EThing
    {
        enum Type
        {
            Thing1,
            Thing2
        };
    }
    
    // 새 열거형
    UENUM()
    enum class EThing : uint8
    {
        Thing1,
        Thing2
    }

 

열거형은 UPROPERTY 로 지원되며, 기존 TEnumAsByte<> 우회법을 대체한다. 열거형 프로퍼티는 바이트뿐만 아니라 어떤 크기라도 될 수 있다.

  // 기존 프로퍼티
    UPROPERTY()
    TEnumAsByte<EThing::Type> MyProperty;
    
    // 새 프로퍼티
    UPROPERTY()
    EThing MyProperty;

블루프린트에 노출되는 열거형은 여전히 uint8 기반이어야 한다.

 

플래그로 사용되는 Enum 클래스는 새로운 ENUM_CLASS_FLAGS(EnumType) 매크로를 사용하여 비트 단위 연산자 전체를 다음과 같이 자동 정의할 수 있다.

	  enum class EFlags
    {
        None = 0x00,
        Flag1 = 0x01,
        Flag2 = 0x02,
        Flag3 = 0x04
    };
    
    ENUM_CLASS_FLAGS(EFlags)

 

한 가지 예외는 truth 컨텍스트에서 플래그를 사용하는 것으로, 이는 언어상의 한계이다. 대신 모든 열거형 플래그에 비교용으로 0으로 설정된 None 열거형을 넣는다.

   // 기존 스타일
    if (Flags & EFlags::Flag1)	
    
    // 새 스타일
    if ((Flags & EFlags::Flag1) != EFlags::None)

 

이동 시맨틱

TArray , TMap , TSet , FString 과 같은 모든 주요 컨테이너 타입에는 move 컨스트럭터와 move 할당 연산자가 있습니다. 이러한 타입을 값으로 전달 또는 반환할 때 종종 자동으로 사용되지만, std::move 의 UE 해당 버전인 MoveTemp 를 통해 명시적으로 호출할 수도 있다.

값으로 컨테이너나 스트링을 반환하는 것은 보통 임시로 복사하는 비용이 없어 표현성에 유용하게 작용할 수 있다. 값 전달 관련 규칙 및 MoveTemp 사용법은 아직도 확립 중이지만, 최적화된 코드베이스 영역 일부에서는 이미 찾아볼 수 있다.

 

디폴트 맴버 이니셜라이저

디폴트 멤버 이니셜라이저는 클래스 자체 내에서 클래스 디폴트값을 정의하는 데 사용할 수 있다.

	    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;
    };

 

코드를 이런 식으로 작성했을 때의 장점은 다음과 같다.

- 여러 컨스트럭터에 걸쳐 이니셜라이저를 복제할 필요가 없다.

- 초기화 순서와 선언 순서가 섞일 일이 없다.

- 멤버 타입, 프로퍼티 플래그, 디폴트값이 모두 한곳에 있으므로 가독성과 유지보수성에 좋다.

 

그러나 다음과 같은 단점도 있습니다

- 디폴트값을 변경하면 모든 종속 파일을 리빌드해야 한다.

- 헤더는 엔진 패치 릴리즈에서 변경할 수 없으므로, 가능한 픽스 종류가 제한될 수 있다.

- 이런 방식으로 초기화시킬 수는 없는 것들도 있습니다. 예를 들면 베이스 클래스, UObject 서브오브젝트, 앞서 선언한(forward-declared) 타입으로의 포인터, 컨스트럭터 아규먼트에서 추론해 낸 값, 여러 단계에 걸쳐 초기화된 멤버 등은 이런 방식으로 초기화시킬 수 없다.

- 헤더에 약간의 이니셜라이저를 두고 나머지는 .cpp 파일의 컨스트럭터에 두게 되면 가독성과 유지보수성에 좋지 않을 수 있다.

※ 디폴트 멤버 이니셜라이저의 실제 사용 여부는 적절한 판단에 맡길 부분입니다. 경험에 의하면, 디폴트 멤버 이니셜라이저는 엔진 코드보다 게임 내 코드 쪽에 적합하다.

 

서드 파티 코드

엔진에서 사용하는 라이브러리에 대한 코드를 수정할 때마다, 변경 내용에 //@UE5 코멘트는 물론 변경 이유를 설명하는 태그를 꼭 달아야 한다. 그러면 그 라이브러리의 새 버전으로 변경 사항을 병합하는 작업이 쉽게 이루어지며, 라이선시가 수정 내용을 쉽게 찾을 수 있다.

또한 엔진에 포함되는 서드 파티 코드는 쉽게 검색 가능한 포맷의 코멘트로 표시해야 합니다. 예를 들어 다음과 같이 할 수 있다.

	// @third party code - BEGIN PhysX
    #include <physx.h>
    // @third party code - END PhysX
    // @third party code - BEGIN MSDN SetThreadName
    // [http://msdn.microsoft.com/ko-kr/library/xcb2z8hs.aspx]
    // 디버거에서 스레드 이름을 설정하는 데 사용됨
    ...
    //@third party code - END MSDN SetThreadName