7. 코딩 표준

JUSTICE_DER·2023년 7월 23일
0

🌵UNREAL

목록 보기
38/42
post-thumbnail

코딩표준

아래의 글들을 참고하였다

코딩표준 = 가독성



파스칼 케이스

웬만하면 스네이크 케이스처럼 _ 언더바를 사용하지 않는다.

클래스 Prefix

  • U - UObject 상속
  • A - AActor 상속
  • S - SWidget 상속
  • F - 그 외 대부분
  • b - bool 변수

함수 이름

  • 기능이 있다면 기능을 표현하도록 명명
  • 기능이 없다면 반환값을 설명하도록 명명
  • bool을 반환하는 함수는 true / false의 질문을 해야한다.
    ex) IsAttack(), IsJumping(), ShouldRun()
// true일 경우 무슨 의미일까요?
bool CheckTea(FTea Tea);

// 이름을 통해 true일 경우 차가 신선하다는 것을 명확히 알 수 있습니다.
bool IsTeaFresh(FTea Tea);

매크로 이름

  • 모두 대문자로 구성
  • 스네이크 케이스
  • 접두사 UE_ 가 사용되어야만 한다

표준 라이브러리

  • 과거에는 UE에서 C 및 C++ 표준 라이브러리를 직접 사용하는 것을 지양했다.
  • 하지만 현재는 사용가능할 정도로 안정적이 되었다.
  • 동일한 API에서 UE 언어와 표준 라이브러리 언어를 혼합하여 사용하지 않도록 한다.
  • 표준 컨테이너와 스트링은 interop 코드를 제외하고는 사용하지 말아야 한다.
    • 즉 CPP에서 구현된 자료구조라이브러리를 사용해서도 안되고,
      String을 사용하지 말라는 것은 추가적인 코드를 적어야하기 때문이다.
      interop기능을 사용하여 String과 언리얼 FString의 호환을 해야만한다.

코드 / 주석

코드 자체의 변수만 보고 어떤 내용인지 이해할 수 있어야 한다.

// 나쁜 예:
t = s + l - b;

// 좋은 예:
TotalLeaves = SmallLeaves + LargeLeaves - SmallAndLargeLeaves;

주석은 코드를 보고 이해할 수 있는 내용보다
어떤 의도의 코드인지를 설명한다.

// 나쁜 예:
// Leaves 증가
++Leaves;

// 좋은 예:
// 찻잎이 더 있다는 것을 알았습니다.
++Leaves;

아래는 주석에 대한 설명을 종합해놓은 예시이다.

/** 마실 수 있는 오브젝트에 대한 인터페이스입니다. */
class IDrinkable
{
public:
    /**
     * 플레이어가 이 오브젝트를 마실 때 호출됩니다.
     * @param OutFocusMultiplier - 반환되면 마신 사람의 포커스에 적용할 배수를 포함합니다.
     * @param OutThirstQuenchingFraction - 반환되면 마신 사람의 갈증이 해소되는 프랙션을 포함합니다(0-1).
     * @warning 마실 것이 적절히 준비된 이후에만 호출하세요.     
     */
    virtual void Drink(float& OutFocusMultiplier, float& OutThirstQuenchingFraction) = 0;
};

/** 차 한 잔입니다. */
class FTea : public IDrinkable
{
public:
    /**
     * 우려내는 데 사용한 물의 용량과 온도가 주어진 경우 차에 대한 델타-맛 값을 계산합니다.
     * @param VolumeOfWater - 우려내는 데 사용한 물의 양(mL)입니다.
     * @param TemperatureOfWater - 물의 온도(켈빈)입니다.
     * @param OutNewPotency - 우리기가 시작된 이후의 차의 효능으로, 0.97에서 1.04까지입니다.
     * @return - 차 농도의 변화를 분당 차 맛 단위(TTU)로 반환합니다.
     */
    float Steep(
        const float VolumeOfWater,
        const float TemperatureOfWater,
        float& OutNewPotency
    );

    /** 차에 감미료를 추가합니다. 같은 당도를 내는 데 필요한 자당의 그램으로 측정합니다. */
    void Sweeten(const float EquivalentGramsOfSucrose);

    /** 일본에서 판매되는 차의 가치(엔화 단위)입니다. */
    float GetPrice() const
    {
        return Price;
    }

    virtual void Drink(float& OutFocusMultiplier, float& OutThirstQuenchingFraction) override;

private:
    /** 엔화 단위 가격입니다. */
    float Price;

    /** 현재 당도로, 자당 그램 단위입니다. */
    float Sweetness;
};

float FTea::Steep(const float VolumeOfWater, const float TemperatureOfWater, float& OutNewPotency)
{
    ...
}

void FTea::Sweeten(const float EquivalentGramsOfSucrose)
{
    ...
}

void FTea::Drink(float& OutFocusMultiplier, float& OutThirstQuenchingFraction)
{
    ...
}

주석을 어떻게 달아야하는지 한눈에 볼 수 있다.

  • 클래스의 함수가 나올때마다, 정의할 때마다 모든 곳에 주석을 달지 않는다.
  • 함수의 주석은 딱 한번 다는데, 함수가 public으로 선언되는 곳에 적는다.
  • Steep처럼 모든 변수값에 대해 설명을 각각 적어도 되지만,
    Sweeten방식처럼 의도를 설명해도 된다.
    이 방식이 보다 깔끔하다고 함
  • 어떤 함수가 override 되더라도 그냥 호출한 쪽의 기능으로 주석을 적는다.
    (현재 override 함수의 호출자에 대한 정보만을 담아야 한다)

최신 CPP

  • 아래에 지원되는 최신 C++ 컴파일러 기능으로 명시된 것 이외의
    컴파일러 전용 언어 기능에 대해서는,
    프리프로세서 매크로나 조건문에 래핑한 경우가 아니라면
    사용하지 말아야 하며, 래핑했다 하더라도 신중하게 사용해야 합니다.
  • static_assert
    • 컴파일 시간에 Assert해주는 CPP기능
  • override / final
    • 당연히 사용할 수 있고, 오히려 사용을 강력히 권장.
  • nullptr
    • Unreal과 CPP의 nullptr과 거의 호환되지만,
      Xbox One으로 빌드할 경우(C++/CX빌드)
      언리얼과 CPP의 nullptr이 좀 다르다.
      호환성을 위해서는 일반적인 TYPE_OF_NULLPTR 매크로사용을 권장한다.
  • auto
    • CPP에선 auto를 사용해선 안된다.
      (사실 사용해도 상관은 없다)
    • 초기화하려는 타입을 항상 명시해주어야만한다.
    • 코드를 읽는 사람이 더 명확히 알아차릴 수 있어야 한다.
    • auto를 사용해도 되는 예외가 존재한다.
      • 변수에 람다를 바인딩하는 경우
        (람다 타입을 표현할 예약어가 구현되어있지 않기 때문)
      • 이터레이터 변수의 경우
        (이터레이터의 명칭이 너무 길어서 쓰는게 오히려 가독성이 떨어진다)
      • 템플릿코드에서 표현식의 타입을 쉽게 식별할 수 없는 경우
        (뭐가 올지 모르니까 그냥 쓴다)

범위기반 for

  • 기존 이터레이터 타입 메서드였던 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);
}

위처럼 쓴다고 한다.

정확히 어떻게 동작하는지 모르겠다.

기존 스타일은 이터레이터를 통해, Map을 순환하면서 이터레이터의
Key(), Value()를 통해 값을 반환하는건 알겠는데,

새 스타일에선 이터레이터가 어떻게 쓰였는지 모르겠고,
다만 key, value를 통해 단순히 필드값으로 접근하는 것은 이해했다.

람다

  • 람다는 자유롭게 사용하라고 함.
    (오히려 람다가 가독성이 떨어진다고 생각했는데..)

  • 작성자의 의도를 선언하므로 코드 리뷰 과정에서 실수를 더욱 쉽게 잡아낼 수 있다고 한다.

/* 람다 종류에 대한 주의사항 설명 /

스테이풀 람다, 대규모 람다, 지연되지 않은 사소한 람다..

이해하고자 한다면 하겠지만 이런건
아직 하이레벨의 스킬로 보인다.

enum

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

// **새 열거형**
UENUM()
enum class EThing : uint8
{
    Thing1,
    Thing2
}

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

// **새 프로퍼티**
UPROPERTY()
EThing MyProperty;

enum은 UENUM() 매크로를 붙여서
위와 같은 형식으로 사용하라고 한다.

  • BP에 노출되려면 열거형은 uint8기반이어야 한다.
// 열거형
UENUM()
enum class EThing : uint8
{
    Thing1,
    Thing2
}

// 플래그로 사용할 열거형
enum class EFlags
{
    None = 0x00,
    Flag1 = 0x01,
    Flag2 = 0x02,
    Flag3 = 0x04
};

ENUM_CLASS_FLAGS(EFlags)
  • 플래그로 사용되는 Enum 클래스는
    새로운 ENUM_CLASS_FLAGS(EnumType) 매크로를 사용해야만 한다.

이동 시맨틱

  • TArray , TMap , TSet , FString 과 같은 모든 주요 컨테이너 타입에는 move 컨스트럭터와 move 할당 연산자가 있습니다. 이러한 타입을 값으로 전달/반환할 때 종종 자동으로 사용되지만, 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;
};

위처럼 헤더에서 변수를 정의함과 동시에,
각 클래스 멤버변수의 디폴트 값을 선언하는 것을 말한다.

장단점이 존재한다.

장점

  • 하나의 클래스의 여러 생성자에,
    같은 변수를 같은 값으로 초기화하는 코드를 중복해서 적지 않을수 있다.
  • 초기화 순서와 선언 순서를 헷갈리지 않을 수 있다.
  • 멤버 타입, UPROPERTY 선언, 초기값이 모두 한 곳에 선언되어서
    가독성이 편리해진다.

단점

  • 초기값을 변경하면 모든 종속 파일들을 리빌드 해야만 한다.
  • 디폴트 멤버 이니셜라이즈를 사용한다면, 모든 이니셜라이즈를 헤더에서 해야하는데,
    그렇지 않고 CPP의 생성자에서 하게 되면 오히려 가독성이 떨어진다.
  • 디폴트 멤버 이니셜라이저를 통해 초기화 할 수 없는 것들도 있다.
    ex) UObject의 SubObject, 베이스클래스, 여러단계에 걸쳐 얻어낸 값 등

경험에 의하면,
디폴트 멤버 이니셜라이저는 엔진의 코드보다 게임 코드에 적합.

그런데 굳이 사용하지 않아도 될 기능같다
그냥 모두 CPP 생성자에 적으면 그만인데..

Switch

switch (condition)
{
    case 1:
        ...
        // falls through

    case 2:
        ...
        break;

    case 3:
        ...
        return;

    case 4:
    case 5:
        ...
        break;

    default:
        break;
}
  • break를 의도적으로 걸지 않아다면 주석을 달아라
  • default는 무조건 만든다.
  • break도 일단 달아둔다.

네임스페이스

  • UHT 에는 네임스페이스가 지원되지 않아 UCLASS, USTRUCT 등을 정의할 때는 사용 불가.
  • 새 API 는 UE:: 네임스페이스에 배치(이상적으로는 중첩된 네임스페이스 - UE::Audio::). Private 일 경우 UE::Audio::Private 같이 사용.
  • Using 선언은 전역 범위에서 사용하지 않기.
  • 네임스페이스 안에서 사용시 일관성 지키기. 매크로는 네임스페이스 내에 있을 수 없지만, 대신 UE_ 접두사 사용(e.g. UE_LOG).

음... 이해가 안된다.. 일단 스킵

물리적 종속성

  • 헤더를 include할 때에는 가능한 상세하게 적는다.
    (특정 기능이 필요한 부분만 추가)
  • 큰 함수는 작은 기능의 함수로 나누는 것이 빌드 시간을 줄인다.
  • InLine함수를 자주 사용하지 마라
    • 사용하지 않는 파일에 InLine함수가 있으면 강제로 리빌드 시킨다
    • 속도는 빠르더라도 파일의 크기가 커진다.

일반적인 스타일 문제들

  • 1 변수의 초기화를 최대한 늦추기
    • 초기화해두고 수백줄동안 사용하지 않는다면, 누군가 실수로 그 값을 바꿀 수 있다.
    • 해당 값을 사용하기 직전에 변수값을 설정하는 것이 바람직.
  • 2 메서드는 하위 메서드로 분할해라
    • 큰 기능으로 구현을 해도 된다.
    • 하지만 큰 기능을 수정해야 하는 경우보다 작은 기능으로 분할해두는 것이 더 편리하다.
    • 해당 작은 기능을 바탕으로 다른 큰 기능을 구현할 수도 있다.
  • 3 파일 끝에 빈줄을 하나 만들어라
    • 그래야만 CPP와 헤더파일이 gcc와 함께 제대로 동작한다고 함.
  • 4 루프에 동일한 연산을 넣지 않는다
    • 대신에 루프 밖에 빼두고 실행되도록 한다.
  • 5 복잡한 표현식은 중간에 계산한 변수를 생성하여 간단히 만든다.
  • 6 익명 리터럴을 피해라
    한줄로 적지 않고, 아래처럼 적는 편이 의도를 단번에 이해하기 쉽다.
    아래처럼 TEXT라고 적는 것보다 무슨 TEXT인지 변수명으로 의도를 명확히 한다.
// 기존 스타일
Trigger(TEXT("Soldier"), 5, true);.

// 새 스타일
const FName ObjectName                = TEXT("Soldier");
const float CooldownInSeconds         = 5;
const bool bVulnerableDuringCooldown  = true;
Trigger(ObjectName, CooldownInSeconds, bVulnerableDuringCooldown);
  • 7 헤더에 스태틱변수를 정의하지 않도록 한다.
    • 해당 헤더가 포함된 다른 모든 인스턴스에 컴파일된다.
    • 대신 extern이란걸 사용

API 디자인 가이드라인

  • 1 함수의 매개변수로 bool을 넣는 것은 피해야하는데,
    플래그값이 매개변수로 들어가는 경우, 특히 지양해야 한다.
// 기존 스타일 // default값을 지정함
FCup* MakeCupOfTea(
	FTea* Tea, 
	bool bAddSugar = false, 
	bool bAddMilk = false, 
	bool bAddHoney = false, 
	bool bAddLemon = false
);

FCup* Cup = MakeCupOfTea(Tea, false, true, true);

// 새 스타일 // 플래그 enum을 생성하는 법 // 추가로 None을 제일 처음으로 둠
enum class ETeaFlags
{
    None,
    Sugar  = 0x01,
    Milk = 0x02,
    Honey = 0x04,
    Lemon = 0x08
};
ENUM_CLASS_FLAGS(ETeaFlags)

FCup* MakeCupOfTea(FTea* Tea, ETeaFlags Flags = ETeaFlags::None);  //default
FCup* Cup = MakeCupOfTea(Tea, ETeaFlags::Milk | ETeaFlags::Honey); //bitwiseOR (비트연산자)

예시로 둔 두 코드가 같은 기능을 하는건 아닌 듯 보인다..
| 가 아니라 &를 두어야 하지 않을까..

  • 2 매개변수가 많은 함수의 경우, 가독성을 위해서 전용 구조체를 고려해봐라
// 기존 스타일
TUniquePtr<FCup[]> MakeTeaForParty(const FTeaFlags* TeaPreferences, uint32 NumCupsToMake, FKettle* Kettle, ETeaType TeaType = ETeaType::EnglishBreakfast, float BrewingTimeInSeconds = 120.0f);

// 새 스타일
struct FTeaPartyParams
{
    const FTeaFlags* TeaPreferences       = nullptr;
    uint32           NumCupsToMake        = 0;
    FKettle*         Kettle               = nullptr;
    ETeaType         TeaType              = ETeaType::EnglishBreakfast;
    float            BrewingTimeInSeconds = 120.0f;
};
TUniquePtr<
  • 3 bool과 FString 오버로드를 주의해야한다.
void Func(const FString& String);
void Func(bool bBool);

Func(TEXT("String")); // 부울 오버로드 호출!

위의 경우에 당연히 FString함수가 호출될 것으로 보이지만,
bool으로 인식해서 대입될 수도 있다고 한다.
주의

결론
내용을 적으면서 느낀거지만,
기능적인 요소보다는 가독성, 협업 위주의 조언이 많았다.
누군가 더 편하게 읽을 수 있도록 하는 것이 역시 코딩 표준인걸까

profile
Time Waits for No One

1개의 댓글

comment-user-thumbnail
2023년 7월 23일

개발자로서 성장하는 데 큰 도움이 된 글이었습니다. 감사합니다.

답글 달기