기술 노트/Google C++ Style Guide

Google C++ Style Guide(2024) - 7장 기타 C++ 기능(Other C++ Features)

anothel 2024. 11. 10. 19:01

7장 기타 C++ 기능(Other C++ Features)

7.1 Rvalue 참조 (Rvalue References)

Rvalue 참조는 임시 객체에만 바인딩될 수 있는 참조 타입으로, && 구문을 사용한다. 예를 들어, void f(std::string&& s);는 std::string 타입의 rvalue 참조를 받는 함수를 선언한다. Rvalue 참조는 객체의 이동과 완벽한 전달을 가능하게 하여 성능 최적화에 유용하다.

Rvalue 참조의 주요 사용 사례

  • 이동 생성자와 이동 할당
    • 연산자std::move를 이용해 rvalue 참조를 전달하면 복사 대신 값을 이동할 수 있어 성능을 크게 향상시킨다. 예를 들어, std::vector<std::string> v1이 있을 때, auto v2(std::move(v1));는 대량의 데이터를 복사하는 대신 포인터 조작만으로 데이터를 이동한다.
  • 복사 불가, 이동 가능 타입
    • 복사 정의가 적절하지 않은 타입에도 이동만 지원하도록 할 수 있어, 함수 인자로 전달하거나 컨테이너에 넣을 수 있다. 예를 들어, std::unique_ptr는 복사할 수 없지만 이동할 수 있어 효율적인 메모리 관리를 가능하게 한다.
  • 완벽한 전달
    • &&를 사용한 전달 참조(forwarding reference)는 rvalue와 lvalue 모두를 전달할 수 있어 완벽한 전달(perfect forwarding)을 구현한다. 이 경우 std::forward를 사용해 원래 타입과 상태를 유지한 채로 다른 함수에 전달할 수 있다.

주의 사항

  • rvalue 참조의 오용
    • rvalue 참조는 여전히 많은 개발자에게 생소하며, 규칙이 복잡할 수 있다. 참조 붕괴(reference collapsing)나 전달 참조의 특수 규칙 등이 혼란을 초래할 수 있다.
  • 잘못된 사용
    • 함수 시그니처에서 rvalue 참조가 사용된 경우, 호출 후에도 객체의 유효한 상태가 유지되어야 한다면 오해를 불러일으킬 수 있다.

Rvalue 참조 사용 권장 지침

다음의 경우에만 rvalue 참조(&& 한정자)를 사용하는 것이 좋다:

  • 이동 생성자 및 이동 할당 연산자 정의
    • 이는 객체를 이동하여 소유권을 이전하는 데 필요하다.
  • && 한정 메서드
    • 메서드가 *this를 소비하여 사용 불가 또는 빈 상태로 남기는 경우에 한해 && 한정자를 사용할 수 있다. 이는 함수 시그니처의 닫는 괄호 뒤에 오는 메서드 한정자에만 해당한다. 일반 함수 매개변수를 소비하고자 할 때는 rvalue 참조 대신 값으로 전달하는 것을 선호한다.
  • 완벽한 전달을 위한 전달 참조
    • std::forward와 함께 전달 참조를 사용해 rvalue와 lvalue 모두를 다른 함수로 전달할 수 있다.
  • 오버로드 쌍 정의
    • Foo&&와 const Foo& 버전의 오버로드를 제공할 수 있다. 단, 보통은 값으로 전달하는 것이 더 선호되지만, 가끔 성능 향상이 필요할 때 오버로드 쌍이 더 좋은 성능을 제공하기도 한다. 이 경우 성능상의 이점이 실제로 있는지 확인하는 것이 중요하다.

이와 같은 경우를 제외하고는 rvalue 참조나 && 한정자를 사용하지 않는 것이 좋다. rvalue 참조는 주로 이동 시나리오에서 성능 최적화에 필요할 때 사용하도록 한다.

7.2 Friends

C++에서는 friend 키워드를 사용하여 특정 클래스나 함수가 다른 클래스의 비공개 멤버에 접근할 수 있도록 허용할 수 있다. friend는 적절한 상황에서 사용될 수 있으며, 클래스의 캡슐화를 확장하지만 깨뜨리지는 않는다.

friend 사용 예시

  • 같은 파일 내에서 정의
    • friend 클래스나 함수는 가능한 한 동일 파일에 정의하는 것이 좋다. 이렇게 하면 독자가 해당 클래스의 비공개 멤버를 사용하는 friend를 찾기 위해 다른 파일을 참조할 필요가 없다.
  • 빌더 클래스
    • FooBuilder 클래스가 Foo 클래스의 friend로 지정될 수 있다. 이렇게 하면 FooBuilder가 Foo의 내부 상태를 적절하게 설정할 수 있으면서도, 해당 상태를 외부에 노출하지 않는다.
  • 테스트 클래스
    • 때로는 단위 테스트 클래스를 friend로 지정해 테스트 클래스가 테스트 중인 클래스의 비공개 멤버에 접근하도록 할 수 있다.
#include <iostream>
#include <string>

class Foo {
 public:
  Foo() : data_(0) {}

  // 데이터 접근 메서드
  int GetData() const { return data_; }

 private:
  int data_;

  // FooBuilder가 Foo의 private 멤버에 접근할 수 있도록 friend로 설정
  friend class FooBuilder;
};

class FooBuilder {
 public:
  // Foo 객체의 내부 상태를 설정할 수 있도록 Foo의 private 멤버에 접근
  FooBuilder& SetData(int value) {
    foo_.data_ = value;
    return *this;
  }

  Foo Build() const {
    return foo_;
  }

 private:
  Foo foo_;
};

// 테스트 클래스를 friend로 설정
class FooTester {
 public:
  static void Test(const Foo& foo) {
    std::cout << "Testing Foo: " << foo.data_ << std::endl; // private 멤버에 접근
  }

 private:
  friend class Foo;
};

int main() {
  // FooBuilder를 통해 Foo 객체를 생성하고 설정
  Foo foo = FooBuilder().SetData(42).Build();

  // FooTester를 사용하여 Foo 객체 테스트
  FooTester::Test(foo);  // Output: Testing Foo: 42

  return 0;
}

friend와 캡슐화

friend를 사용하면 클래스의 캡슐화 경계를 확장하여 특정 클래스나 함수에만 접근을 허용할 수 있다. 이는 모든 클래스에 접근을 허용하기 위해 멤버를 public으로 만드는 것보다 나은 방법이 될 수 있다. 그러나 대부분의 경우, 클래스는 다른 클래스와 public 멤버를 통해서만 상호작용해야 한다.

결론

friend는 클래스 간의 특별한 관계를 설정할 때 유용하지만, 과도하게 사용하지 않고 필요할 때만 적용하는 것이 좋다. 이를 통해 클래스의 캡슐화를 유지하면서도, 특정 상황에서 비공개 멤버에 대한 제한된 접근을 허용할 수 있다.

7.3 예외 처리 (Exceptions)

Google C++ 스타일 가이드에서는 예외 사용을 권장하지 않으며, 대신 오류 코드와 같은 대안 사용을 지지한다. 이는 Google의 기존 코드베이스가 예외 처리에 대비되어 있지 않으며, 예외가 통합 유지보수 및 디버깅에 불편을 초래하기 때문이다.

예외 사용의 장단점

장점

  • 에러 처리
    • 예외는 깊게 중첩된 함수에서 발생하는 오류를 더 높은 수준에서 일관되게 처리할 수 있어, 오류 코드에 의존할 때 발생할 수 있는 복잡한 작업을 줄여준다.
  • 일관성
    • 대부분의 현대 언어(Java, Python 등)가 예외 처리를 사용하므로, C++에서 예외를 사용하는 것이 다른 언어와의 일관성을 높인다.
  • 생성자 실패
    • 예외는 생성자가 실패하는 유일한 방법이다. 이를 Init() 메서드나 팩토리 함수로 대체할 수 있지만, 이는 힙 할당이나 새로운 "invalid" 상태를 요구하게 된다.

단점:

  • 복잡한 제어 흐름
    • 예외는 코드에서 예상치 못한 지점에서 반환될 수 있어, 프로그램의 제어 흐름을 이해하기 어렵게 만든다. 이는 유지보수성과 디버깅의 어려움을 초래할 수 있다.
  • 예외 안전성
    • 예외 안전성은 RAII와 특정 코딩 방식을 필요로 하며, 전체 호출 그래프를 이해해야만 오류 없는 코드를 작성할 수 있어 코드 복잡도가 증가한다.
  • 성능 비용
    • 예외 처리를 활성화하면, 각 바이너리에 추가 데이터가 포함되며 컴파일 시간이 증가하고 메모리 압박이 커질 수 있다.
  • 잘못된 사용 위험
    • 예외가 사용 가능할 경우 개발자들이 이를 부적절한 상황에서도 사용하려 할 수 있으며, 특히 잘못된 사용자 입력 등에서는 예외 사용이 권장되지 않는다.

예외 사용에 대한 대안

Google의 기존 C++ 코드베이스는 예외 처리를 지원하지 않기 때문에, 새로운 프로젝트에서 예외를 도입하면 기존 예외 없는 코드와의 통합에 문제가 발생할 수 있다. Google은 오류 코드와 단언문(assertion)을 대안으로 제시하며, 이를 통해 예외 없이도 충분히 견고한 코드를 작성할 수 있다고 본다.

예외 사용 금지에 대한 실용적 이유

  • 기존 코드 호환성
    • Google의 대부분의 코드가 예외를 허용하지 않는 구조로 작성되었기 때문에, 예외를 도입하면 기존 코드와의 호환성 문제가 발생할 수 있다.
  • 오픈 소스 프로젝트와의 일관성
    • Google의 오픈 소스 프로젝트에서도 예외 사용을 지양하여, Google 내부와 외부 프로젝트의 일관성을 유지하고자 한다.

예외를 사용하지 않는 권장 사항은 실용적인 이유에 기반한다. 처음부터 새 프로젝트를 시작할 경우 예외를 사용할 수 있지만, 기존 코드와의 통합이 어려운 경우 이를 피하는 것이 좋다.

예외 처리 기능의 금지 사항

  • std::exception_ptr 및 std::nested_exception과 같은 예외 관련 기능도 사용하지 않는다.

예외

  • Windows 코드에서는 예외 사용이 허용된다.

7.4 noexcept

noexcept는 함수가 예외를 던지지 않는다고 명시하는 데 사용된다. 함수가 noexcept로 지정되었지만 예외가 발생하면, 프로그램은 std::terminate를 통해 비정상 종료된다.

noexcept의 장점

  • 성능 향상
    • noexcept로 명시된 함수는 스택 언와인딩(stack unwinding)용 추가 코드를 생략할 수 있어, 성능을 향상시킬 수 있다.
  • 이동 생성자와의 최적화
    • 이동 생성자에 noexcept를 지정하면, std::vector<T>::resize()와 같은 경우 객체를 복사하지 않고 이동만 수행하게 되어 성능이 크게 향상될 수 있다.

noexcept 사용 시 주의사항

  • 예외가 비활성화된 환경에서는 noexcept 지정이 올바른지 확인하기 어렵다. 예외가 비활성화된 경우 무조건 noexcept를 지정하는 것을 선호한다.
  • 되돌리기 어려움: noexcept로 지정된 함수에서 예외가 발생할 수 있도록 변경하기는 어렵다. noexcept는 함수가 절대 예외를 던지지 않음을 보장하므로, 이를 제거하면 호출자가 의존하던 보장이 무효화될 수 있다.

noexcept 사용 권장 지침

  • 이동 생성자
    • 이동 생성자에는 무조건 noexcept를 지정하는 것이 좋다. 이는 성능상의 이점이 거의 항상 발생하기 때문이다.
  • 기타 함수
    • 함수가 예외를 던지지 않으며, noexcept 지정이 성능 향상에 기여할 수 있다고 판단될 때 프로젝트 리더와 논의 후 사용할 수 있다.

조건부 noexcept

  • 예외를 전혀 사용하지 않는 환경에서는 noexcept를 무조건적으로 사용한다.
  • 조건부 noexcept: 예외가 발생할 가능성이 낮은 경우에는 조건부로 noexcept를 사용할 수 있다. 예를 들어, std::is_nothrow_move_constructible 같은 타입 특성을 통해 조건부 noexcept를 지정할 수 있다.
template <typename T>
auto my_move(T&& obj) noexcept(std::is_nothrow_move_constructible<T>::value) {
    return std::move(obj);
}
  • 메모리 할당 실패에 대한 처리
    • 메모리 할당 실패는 예외적으로 발생할 수 있지만, 많은 경우 이를 치명적인 오류로 처리하는 것이 적절하다. 따라서 메모리 할당 실패 시 복구를 시도하지 않고, noexcept로 명시해 프로그램이 종료되도록 할 수 있다.

인터페이스 간소화 우선

예외 발생 가능성을 모두 다루기 위해 복잡한 noexcept 조건을 작성하는 대신, 인터페이스를 간소화하는 것이 좋다. 예를 들어, 해시 함수가 예외를 던질 수 있는 경우, 이를 지원하는 대신 해시 함수는 예외를 던지지 않는다고 문서화하고, 무조건 noexcept를 사용하는 것이 더 간결하고 관리하기 쉽다.

결론적으로, noexcept는 성능을 최적화할 수 있는 경우에만 사용하고, 필요에 따라 무조건적 혹은 조건부로 설정하는 것이 바람직하다.

7.5 런타임 타입 정보 (Run-Time Type Information, RTTI)

RTTI(Run-Time Type Information)는 객체의 C++ 클래스를 런타임에 확인할 수 있는 기능이다. typeid나 dynamic_cast를 사용하여 런타임에 객체의 타입을 확인할 수 있지만, 이는 유지보수와 설계에 있어 주의가 필요하다. RTTI는 일부 단위 테스트에서는 유용하지만, 새 코드에서는 지양하는 것이 좋다.

RTTI의 장단점

  • 장점
    • 테스트
      • 팩토리 클래스 테스트 시 생성된 객체가 예상한 동적 타입인지 확인할 때 유용하다.
    • 여러 추상 객체 처리
      • 여러 객체의 타입을 비교해야 할 때, dynamic_cast를 통해 특정 타입의 객체인지 확인할 수 있다.
  • 단점
    • 설계 문제의 신호
      • 런타임에 타입을 확인해야 하는 경우, 클래스 계층 구조에 설계상의 문제가 있을 수 있다.
    • 유지보수 어려움
      • RTTI를 사용한 타입 기반의 의사결정 구조나 switch문은, 클래스가 확장될 때마다 수정이 필요하여 유지보수가 어렵다.
    • 오남용 위험
      • RTTI는 남용하기 쉽기 때문에, 코드가 복잡해지고 의도 파악이 어려워질 수 있다.

RTTI 사용을 피하기 위한 대안

  • 가상 메서드
    • 특정 서브클래스 타입에 따라 다른 코드 경로를 실행해야 할 때, 가상 메서드를 사용하여 객체 내에서 직접 구현하는 것이 이상적이다.
  • 더블 디스패치와 방문자 패턴(Visitor Pattern)
    • 처리 로직이 객체 외부에 있어야 할 경우, 방문자 패턴을 사용하여 객체의 타입에 따라 다른 동작을 실행할 수 있다. 이는 타입 시스템을 이용해 외부 처리 코드가 객체의 타입을 결정하도록 한다.
  • 캐스트 사용 지침
    • 프로그램 로직이 특정 베이스 클래스 인스턴스가 특정 파생 클래스의 인스턴스임을 보장할 때는 dynamic_cast를 자유롭게 사용할 수 있다. 그러나, 이 경우 대부분 static_cast를 대체로 사용할 수 있다.

잘못된 RTTI 사용 예시

타입에 따라 조건문을 사용해 의사결정을 하는 코드는 코드가 잘못된 방향으로 설계되고 있음을 의미한다.

if (typeid(*data) == typeid(D1)) {
  ...
} else if (typeid(*data) == typeid(D2)) {
  ...
} else if (typeid(*data) == typeid(D3)) {
  ...
}

이러한 구조는 서브클래스가 추가될 때마다 코드 수정이 필요하고, 서브클래스의 속성이 변경되면 관련된 코드 전체를 찾아 수정해야 하므로 유지보수가 어렵다.

RTTI 대체 수단과 주의사항

  • RTTI 유사 기능을 직접 구현하지 말 것
    • 클래스 계층 구조에 타입 태그를 추가하는 등의 대체 수단은 RTTI 사용과 다르지 않으며, 코드의 의도를 숨기게 된다.

결론적으로, RTTI는 일부 테스트 코드에서는 유용하지만, 새 코드 작성 시에는 설계상의 문제로 간주하고 사용을 피하는 것이 바람직하다. RTTI 사용을 지양하고, 가능한 한 가상 메서드와 방문자 패턴 등 다른 방식으로 문제를 해결하는 것이 좋다.

7.6 캐스팅 (Casting)

C++ 스타일 캐스팅을 사용하여 명확하고 안전한 타입 변환을 수행하는 것이 좋다. C 스타일 캐스트((int)x)는 캐스트 작업의 의미가 모호하고, 검색 시 눈에 잘 띄지 않기 때문에 권장되지 않는다.

C++ 스타일 캐스트의 종류와 사용 지침

  • 중괄호 초기화(Brace Initialization)
    • 산술 타입 변환에는 중괄호 초기화를 사용한다.
    • 예를 들어, int64_t y = int64_t{1} << 42;는 산술 연산에서 안전하게 타입을 변환한다.
    • 정보 손실이 발생할 수 있는 변환은 컴파일 단계에서 에러로 처리되어, 안전성이 높아진다.
  • absl::implicit_cast
    • 타입 계층을 위로 안전하게 캐스팅할 때 사용한다. 예를 들어, Foo*를 SuperclassOfFoo*로 캐스팅할 때 사용한다.
    • 대다수의 경우, C++는 이를 자동으로 처리하지만, ?: 연산자 사용 등 일부 상황에서는 명시적 상향 캐스팅이 필요하다.
  • static_cast
    • 값 변환을 수행하는 C 스타일 캐스트의 대체로 사용하며, 명확한 변환을 할 때 권장된다.
    • 클래스에서 상위 클래스로의 명시적 업 캐스팅이나 상위 클래스에서 하위 클래스로의 다운 캐스팅에도 사용된다. 단, 다운 캐스팅 시에는 객체가 실제로 해당 하위 클래스의 인스턴스임을 확실히 알아야 한다.
  • const_cast
    • const 한정자를 제거할 때 사용한다. 예를 들어, const Foo*를 Foo*로 변경할 수 있다.
  • reinterpret_cast
    • 포인터 타입을 정수형이나 다른 포인터 타입으로 변환하는 위험한 변환에 사용한다. 예를 들어, 포인터를 void*로 캐스팅할 때 사용된다.
    • 엄격한 별칭 규칙(aliasing rules)을 이해하고 있는 경우에만 사용한다.
    • 가능하면, 포인터를 역참조하여 std::bit_cast로 비트를 직접 변환하는 것이 더 안전할 수 있다.
  • std::bit_cast
    • 동일한 크기를 가진 다른 타입으로 비트 수준의 변환을 수행할 때 사용한다.
    • 예를 들어, double의 비트를 int64_t로 해석할 때 std::bit_cast를 사용하여 안전하게 타입을 변환할 수 있다.
  • dynamic_cast
    • RTTI 관련 사용 지침에 따라 런타임 타입 확인이 필요한 경우에만 사용한다. 예를 들어, 베이스 클래스 포인터가 특정 파생 클래스의 인스턴스인지 확인할 때 사용한다.

권장 사항

  • 일반적으로 C 스타일 캐스트는 사용하지 않는다.
  • 캐스트가 필요할 때는 명확하고 안전한 C++ 스타일 캐스트(예: static_cast, const_cast)를 사용하여 의도를 명확히 하고, 검색 시 쉽게 식별 가능하게 한다.
  • 중괄호 초기화는 산술 타입 변환에서 가장 안전한 방법이므로 가능한 경우 이를 사용한다.

이러한 캐스트 지침을 따르면 코드의 안전성, 가독성, 유지보수성을 높일 수 있다.

7.7 스트림 (Streams)

스트림은 C++에서 표준 입출력 추상화로, <iostream>을 통해 제공되며 디버그 로깅과 테스트 진단 등 간단한 I/O 작업에서 널리 사용된다. 스트림을 적절하게 사용하는 것이 좋지만, 단순하고 직관적인 용도로 제한하는 것이 바람직하다.

스트림 사용의 장점

  • 사용성
    • <<와 >> 연산자는 쉽게 배우고 사용할 수 있는 API를 제공한다. 포터블하고 재사용 가능하며, 사용자 정의 타입을 지원하므로 printf와 비교해 유연성이 뛰어나다.
  • 콘솔 I/O 지원
    • std::cin, std::cout, std::cerr 등 C++ 표준 라이브러리는 콘솔 입출력을 원활하게 지원한다.

스트림 사용의 한계와 주의사항

  • 상태 변화 문제
    • 스트림은 포맷 설정 등을 통해 상태가 변경되며, 이러한 변경은 스트림의 모든 이전 이력에 영향을 받을 수 있다. 매번 상태를 초기화하지 않으면 다른 코드의 영향을 받을 수 있다.
  • 정확한 제어 어려움
    • 코드와 데이터가 << 연산자로 혼합되어 있고, 오버로드된 연산자를 통해 다른 변환이 선택될 가능성도 있어 출력 제어가 까다롭다.
  • 국제화 문제
    • << 연산자를 통해 문장 구성을 하게 되면 코드에 언어 순서가 고정되어, 국제화(I18N)와 지역화(L10N) 지원이 어려워진다.
  • 성능
    • 많은 << 오버로드를 해결하는 데 컴파일러 리소스를 많이 소비하므로, 코드베이스가 클 경우 파싱 및 의미 분석 시간의 20%가량을 소모할 수 있다.

스트림 사용 권장 지침

  • 로컬, 간단한 I/O
    • 스트림은 임시적, 로컬, 개발자용, 사람 읽기 가능한 I/O에 적합하다. 개발자 간의 간단한 출력이 필요할 때 사용하고, 외부 사용자용이거나 보안이 필요한 데이터에는 사용을 피하는 것이 좋다.
  • 로깅 및 문자열 라이브러리 대체 사용
    • std::cerr나 std::clog 대신 로그 라이브러리를 사용하는 것이 일반적으로 더 나은 선택이다. 문자열 처리에는 absl/strings나 그에 준하는 라이브러리가 std::stringstream보다 더 효율적이다.
  • 외부 사용자나 신뢰할 수 없는 데이터에 대한 I/O에는 스트림을 사용하지 않는다. 대신, 템플릿 라이브러리를 사용하여 국제화, 지역화, 보안 등을 지원하는 것이 바람직하다.

스트림 사용 시 피해야 할 요소

  • 상태 관련 API 사용 자제
    • imbue(), xalloc(), register_callback() 등 상태를 변화시키는 API는 사용하지 않는다. 숫자 형식, 정밀도, 패딩 등을 제어할 때는 스트림 조작자보다 absl::StreamFormat() 같은 명시적인 포맷팅 함수 사용을 권장한다.

<< 연산자 오버로드

  • 값을 표현하는 타입에만 오버로드
    • <<는 사람이 읽을 수 있는 값을 표현하는 타입에만 오버로드하는 것이 좋다. 내부 구현 세부 정보를 노출하지 말고, 디버깅 용도로 객체 내부 정보를 출력할 때는 DebugString()과 같은 명명된 함수를 사용한다.

스트림은 개발자용 출력에 적합하지만, 외부 사용자용 데이터 처리에는 신중해야 한다. << 오버로드는 값을 표현하는 경우에만 사용하고, 디버깅 정보는 별도의 함수로 구현하여 유지보수성과 가독성을 높이는 것이 좋다.

7.8 전위 증감 연산자 (Preincrement and Predecrement)

증감 연산자를 사용할 때는 전위 형태(++i나 --i)를 사용하는 것이 권장된다. 후위 형태(i++, i--)는 값이 수정되기 이전의 값을 사용하는 경우에만 사용한다.

전위와 후위 연산자의 차이

  • 후위 연산자(i++ / i--)
    • 표현식의 값은 변경 전의 값이 반환된다. 변경 전의 값을 복사해둔 다음 연산이 이루어지므로, 불필요한 복사 비용이 발생할 수 있다.
  • 전위 연산자(++i / --i)
    • 표현식의 값은 변경 후의 값이 반환되며, 복사가 필요하지 않아 더 효율적이다.

권장 사용 방법

  • 전위 증감 사용
    • 대부분의 경우 전위 형태가 더 가독성이 높고 효율적이므로, 특별히 후위 연산자가 필요한 경우가 아니라면 전위 연산자를 사용하는 것이 좋다.
  • 후위 연산자가 필요한 경우
    • 후위 연산자의 결과값이 필요할 때(예: int x = i++;)에만 후위 연산자를 사용한다.

예시

// 전위 연산자 사용 (권장)
for (int i = 0; i < n; ++i) {
  // 작업 수행
}

// 후위 연산자가 필요한 경우에만 사용
int x = i++; // i를 증가시키기 전의 값이 x에 할당됨

전통적으로 C에서 후위 연산자가 널리 사용되었으나, 전위 증감이 가독성과 효율성 면에서 더 유리하다. 후위 연산이 꼭 필요한 경우가 아니라면 전위 형태를 사용하도록 한다.

7.9 const 사용 (Use of const)

API 설계에서 const는 가능한 곳에 항상 사용하여 코드의 가독성, 안전성, 그리고 타입 검사를 강화하는 것이 좋다. const는 변수나 함수가 값을 변경하지 않음을 나타내며, constexpr는 컴파일 타임에 상수가 필요할 때 더 나은 선택이 될 수 있다.

const 사용의 장점

  • 가독성
    • 코드에서 변수나 매개변수가 변경되지 않음을 나타내어 코드의 사용 방식을 쉽게 이해할 수 있다.
  • 컴파일러의 타입 검사
    • const는 컴파일러가 더 엄격한 타입 검사를 수행하게 해, 코드 최적화에 도움을 줄 수 있다.
  • 코드 안정성
    • const는 호출된 함수가 변수를 변경할 수 없도록 제한하므로, 특히 멀티스레드 환경에서 락 없이 안전하게 사용할 함수를 구분할 수 있게 해준다.

const의 적용 위치

  • 참조 및 포인터 매개변수
    • 함수가 참조나 포인터를 통해 인자를 받으면서 인자를 수정하지 않는다면, const T& 또는 const T*를 사용하여 불변임을 보장한다.
void PrintValue(const std::string& text);
void ProcessData(const int* data);

 

  • 값 전달 매개변수
    • 값으로 전달된 매개변수에 const를 붙여도 호출자에게는 아무런 효과가 없으므로, 값 전달 매개변수에는 const를 사용하지 않는 것이 권장된다.
  • 멤버 함수
    • 객체의 상태를 변경하지 않는 함수는 const 한정자를 붙여 호출자에게 함수가 안전함을 명시한다.
class Foo {
 public:
    int GetValue() const; // 객체 상태를 변경하지 않음
};
  • 지역 변수
    • 지역 변수에 const를 사용하는 것은 강제되지 않지만, 필요에 따라 선택적으로 사용하여 코드 가독성을 높일 수 있다.

멀티스레드 환경에서의 const

  • 클래스의 모든 const 연산은 상호 호출해도 안전해야 하며, 그렇지 않다면 클래스가 "스레드-안전하지 않음"으로 명확히 문서화되어야 한다.

const 위치

  • 권장 형태
    • const int* foo와 같이 변수형 앞에 const를 두는 것을 권장한다. 이는 영어의 형용사-명사 순서와 같아 가독성이 높다고 여겨진다.
  • 그러나, 특정 코드베이스나 주변 코드와 일관성을 유지하는 것이 더 중요하다.

예시

class MyClass {
 public:
  int GetValue() const;                // 객체 상태를 변경하지 않음
  void SetValue(const std::string& v); // 매개변수 수정하지 않음
  void ProcessData(const int* data);   // 포인터를 통한 읽기 전용 접근
};

const 사용은 코드에서 읽기와 쓰기를 구분하고, 멀티스레드 환경에서의 안전성을 보장하는 데 매우 유용하다. const를 통해 코드의 의도를 명확히 하고, 불필요한 오류와 버그를 예방할 수 있다.

 

7.10 Use of constexpr, constinit, and consteval

constexpr는 진정한 상수(컴파일 시점에 고정된 값)를 정의하거나 상수 초기화를 보장하는 데 사용되며, constinit은 상수 초기화를 요구하지만 비상수 변수에 사용된다. consteval은 함수나 표현식이 컴파일 타임에만 평가되도록 제한한다.

사용 지침

  • constexpr 사용
    • 진정한 상수 정의
      • constexpr는 상수가 컴파일/링크 시점에 고정된 값임을 나타낸다.
    • 상수 초기화 보장
      • 변수가 constexpr로 선언되면 상수 초기화를 보장하며, 주로 상수 표현식을 정의할 때 사용된다.
    • 사용 사례
      • 부동 소수점 표현식을 포함한 상수 정의
      • 사용자 정의 타입 상수 정의
      • 함수 호출을 포함한 상수 정의
    • 예시
constexpr double pi = 3.14159;
constexpr int square(int x) { return x * x; }
constexpr int area = square(5);
  • constinit 사용
    • 상수 초기화가 필요한 비상수 변수에 사용된다. 컴파일러가 초기화를 항상 상수 초기화로 보장한다.
    • 예시
constinit int x = 42; // 상수 초기화 보장, 이후에 값 변경 가능
  • consteval 사용
    • 컴파일 타임에만 실행 가능한 함수를 정의하는 데 사용한다. consteval 함수는 런타임 호출이 불가능하며, 컴파일 시점에만 평가된다.
    • 예시
consteval int get_compile_time_value() { return 5; }
constexpr int val = get_compile_time_value(); // 컴파일 타임에서 평가

주의사항

  • 지나친 constexpr 사용 지양
    • 불필요하게 많은 것을 constexpr로 지정하면 나중에 상수를 비상수로 변경하는 데 어려움이 있을 수 있다. constexpr에 대한 현재 제한으로 인해, 허용된 범위 내에서만 constexpr 함수와 생성자를 정의해야 한다.
  • 복잡한 함수 정의 지양
    • constexpr나 consteval을 사용해 인라인을 강제하려 하지 말고, 함수 정의가 지나치게 복잡해지는 것을 피해야 한다.

요약

  • constexpr
    • 진정한 상수와 상수 초기화가 필요한 함수에 사용
  • constinit
    • 상수 초기화가 필요하지만, 이후 변경 가능한 변수에 사용
  • consteval
    • 컴파일 타임에만 평가되는 함수에 사용

constexpr, constinit, 그리고 consteval은 컴파일 타임과 런타임의 경계를 명확히 하여 코드의 안전성과 성능을 높이는 데 유용하다.

7.11 정수 타입 (Integer Types)

C++의 기본 정수 타입 중 int를 제외하고는 사용하지 않는 것이 권장된다. 크기 보장이 필요한 경우 <cstdint>에 정의된 정밀 폭 타입(e.g., int16_t, uint32_t, int64_t)을 사용하는 것이 좋다.

정수 타입 선택 가이드라인

  • 일반적인 정수
    • int를 사용한다. int는 적어도 32비트 이상임을 가정할 수 있지만, 반드시 그 이상이라는 보장은 없다. 루프 카운터와 같이 큰 수를 다룰 필요가 없는 정수에는 int를 사용한다.
  • 64비트 정수
    • 큰 수를 다룰 필요가 있을 때는 int64_t를 사용한다. 예를 들어, 값이 2^31 이상이 될 가능성이 있다면 64비트 타입을 사용하여 충분한 공간을 확보한다.
  • 정확한 크기가 필요한 정수
    • 특정 크기의 정수형이 필요한 경우에는 <cstdint>에 정의된 정밀 폭 타입(int16_t, uint32_t 등)을 사용한다. 이는 플랫폼에 관계없이 일관된 크기를 보장한다.
int32_t small_int = 1000;    // 32비트 정수
uint64_t large_int = 100000; // 64비트 무부호 정수
  • 64비트 이상의 값이 필요한 경우: 만약 연산 과정에서 중간값이 클 수 있다고 예상되면, 해당 변수뿐만 아니라 중간 계산에 필요한 타입도 충분한 크기로 설정하는 것이 좋다. 의심이 된다면 더 큰 타입을 선택하는 것이 안전하다.
  • unsigned 타입 사용 자제: uint32_t 같은 무부호 타입은 특정 상황에서만 사용하고, 가능한 한 피하는 것이 좋다.
    • 비트 패턴 표현이나 모듈러 산술과 같이 값이 숫자보다 비트 패턴으로 사용될 때만 무부호 타입을 사용한다.
    • 무부호 타입을 단순히 "음수일 수 없다"는 의미로 사용하지 않는다. 대신, 음수가 될 수 없음을 보장해야 한다면 어설션(assertion)을 사용한다.
  • 컨테이너 크기
    • 컨테이너의 크기를 반환하는 경우, 가능한 모든 사용 사례를 수용할 수 있도록 충분히 큰 타입을 사용한다. 크기가 너무 작으면 문제가 발생할 수 있으므로, 의심스러울 때는 더 큰 타입을 선택한다.

정수 타입 변환 주의사항

정수형 간의 변환이나 승격은 예기치 않은 동작이나 보안 문제를 일으킬 수 있으므로 주의해야 한다. 특히 서명된 정수와 무부호 정수의 혼합은 오류 가능성이 크다.

무부호 정수에 대한 권장사항

  • 비트필드와 모듈러 산술에 무부호 정수가 적합하다.
  • C++ 표준에서는 컨테이너의 크기를 표현할 때 무부호 타입을 사용하지만, 이는 역사적 배경에 따른 것으로 대부분의 경우 실수로 간주된다. 무부호 산술은 단순 정수 모델이 아니라 모듈러 산술을 모델링하므로, 오버플로/언더플로 시 문제가 발생할 수 있다.

요약: 무부호 타입은 비트필드나 모듈러 산술에 한정하여 사용하며, 음수가 될 수 없음을 나타내기 위해 사용하지 않는다.

권장 사항 요약

  • int 사용
    • 대부분의 일반적인 정수에 사용.
for (int i = 0; i < 10; ++i) {
    // 루프 카운터로 일반적인 정수 사용
}
  • int64_t 사용
    • 큰 수가 필요한 경우.
int64_t file_size = 5000000000; // 5GB와 같은 큰 값을 저장
  • 정밀 폭 타입 사용
    • 특정 크기 보장이 필요할 때.
#include <cstdint>  // 정밀 폭 타입을 사용하기 위해 필요

int16_t small_number = 32767;                    // 16비트 정수, -32768 ~ 32767
uint16_t header = 0xABCD;                        // 16비트 고정 크기, 프로토콜 헤더 등
uint32_t positive_large_number = 1000000000;     // 32비트 무부호 정수, 0 ~ 2^32-1
int32_t offset = 1024;                           // 파일 내 오프셋을 32비트 정수로 저장
int64_t very_large_number = 9223372036854775807; // 64비트 정수
  • 무부호 타입 사용 지양
    • 특정 상황(비트필드, 모듈러 산술)을 제외하고 무부호 타입은 사용하지 않는다.
uint32_t flags = 0b1010; // 비트 플래그 저장

이러한 지침을 따르면 코드의 일관성을 유지하고, 예기치 않은 오류와 보안 문제를 방지할 수 있다.

7.12 부동 소수점 타입 (Floating-Point Types)

C++의 기본 부동 소수점 타입 중에서는 float와 double만 사용하며, 이 두 타입은 각각 IEEE-754 표준의 binary32(32비트 부동 소수점)와 binary64(64비트 부동 소수점)을 나타낸다고 가정할 수 있다.

사용 지침

  • float
    • 32비트 부동 소수점 수로, 단정밀도를 제공한다. 메모리 절약이 필요하거나 높은 정밀도가 필요하지 않은 경우 사용한다.
float pi = 3.14159f;
  • double
    • 64비트 부동 소수점 수로, 배정밀도를 제공한다. 정밀도가 더 중요한 계산이나 값이 더 넓은 범위를 커버해야 할 때 사용한다.
double large_number = 1.234567890123456;

금지된 타입

  • long double은 사용하지 않는다. long double은 플랫폼에 따라 크기와 정밀도가 다를 수 있어 비일관적인 결과를 초래할 수 있다. 이로 인해 코드의 이식성이 떨어지므로, Google 스타일 가이드에서는 사용을 권장하지 않는다.

요약

  • float
    • 단정밀도가 필요한 경우 사용.
  • double
    • 배정밀도와 넓은 범위가 필요한 경우 사용.
  • long double은 사용하지 않음: 플랫폼 간 비일관성을 피하기 위해.

정밀도와 메모리 사용의 균형을 고려해 float와 double을 선택하고, long double은 피하여 코드의 일관성과 이식성을 유지한다.

7.13 아키텍처 이식성 (Architecture Portability)

아키텍처 간 이식성을 고려한 코드 작성이 필요하다. 특정 프로세서에 종속된 CPU 기능에 의존하지 말고, 다양한 플랫폼에서 일관되게 작동할 수 있는 코드를 작성해야 한다.

이식성 고려 지침

  • 타입 안전한 숫자 포맷팅 사용
    • printf 계열 함수 대신 타입 안전한 포맷팅 라이브러리(absl::StrCat, absl::Substitute, absl::StrFormat 또는 std::ostream)를 사용한다. 이는 잘못된 타입 변환으로 인한 오류를 줄여준다.
  • 구조화된 데이터는 직렬화하여 처리
    • 데이터를 프로세스 내부에서 가져오거나 내보낼 때는 메모리 표현을 직접 복사하지 말고 Protocol Buffers 같은 직렬화 라이브러리를 사용한다. 이렇게 하면 다양한 아키텍처 간에 데이터 일관성을 유지할 수 있다.
  • 메모리 주소를 정수로 처리할 때는 uintptr_t 사용
    • 메모리 주소를 정수형으로 다룰 경우, uintptr_t를 사용해 CPU 아키텍처에 따라 크기가 조정되는 정수형을 사용한다. 이는 uint32_t나 uint64_t보다 이식성이 높다.
  • 64비트 상수 초기화에는 중괄호 초기화 사용
    • 64비트 상수를 정의할 때는 중괄호 { }를 사용해 명확히 타입을 지정하여 초기화한다.
int64_t my_value{0x123456789};
uint64_t my_mask{uint64_t{3} << 48};
  • 이식 가능한 부동 소수점 및 정수 타입 사용
    • float와 double만 사용하고, 플랫폼에 따라 크기와 정밀도가 달라지는 long double은 피한다. 또한, int16_t, int32_t, int64_t 등 정밀 폭 타입을 사용하며, short, long, long long 등의 타입은 피한다.

7.14 전처리기 매크로 사용 (Preprocessor Macros)

전처리기 매크로는 가능한 한 피하는 것이 좋다, 특히 헤더 파일에서 정의하지 않도록 한다. 매크로 대신 인라인 함수, 열거형(enum), 상수 변수(const) 등을 사용하는 것이 바람직하다. 매크로는 전역 범위를 가지므로 코드 가독성과 유지보수성에 많은 문제를 일으킬 수 있다.

매크로 사용의 문제점

  • 가시성과 예측 불가능성
    • 매크로는 코드가 컴파일러에 전달되기 전에 대체되므로, 코드의 가시성을 떨어뜨리고 예상치 못한 동작을 일으킬 수 있다.
  • 인터페이스 정의에 매크로 사용 금지
    • 특히 C++ API의 일부를 매크로로 정의하는 경우, 잘못된 사용으로 인한 오류 메시지가 복잡해지며, 리팩토링과 코드 분석 도구가 제대로 작동하지 않는다. 예를 들어, 아래와 같은 방식은 피해야 한다.
class WOMBAT_TYPE(Foo) {
 public:
    EXPAND_PUBLIC_WOMBAT_API(Foo)
    EXPAND_WOMBAT_COMPARISONS(Foo, ==, <)
};

매크로 대안

  • 인라인 함수
    • 성능이 중요한 코드에 매크로를 사용하는 대신 인라인 함수를 사용한다.
// 비추천: 매크로로 간단한 수학 연산을 정의
#define SQUARE(x) ((x) * (x))

// 권장: 인라인 함수로 정의하여 타입 안전성과 가독성 확보
inline int Square(int x) {
    return x * x;
}
  • 상수 변수
    • 상수를 저장할 때 매크로 대신 const 변수를 사용한다.
// 비추천: 매크로를 사용해 상수를 정의
#define PI 3.14159

// 권장: const 변수로 상수를 정의
const double kPi = 3.14159;
  • 참조 변수
    • 긴 변수 이름을 줄이기 위해 매크로를 사용하지 말고 참조 변수를 사용한다.
  • 조건부 컴파일 지양
    • 특정 코드 조건을 위해 매크로를 사용하는 대신 다른 방법을 고려한다. 단, 헤더 파일의 중복 포함 방지를 위한 #define 가드는 예외다.
// 비추천: 디버그 메시지를 매크로로 정의
#ifdef DEBUG
#define LOG_DEBUG(msg) std::cout << "DEBUG: " << msg << std::endl;
#else
#define LOG_DEBUG(msg)
#endif

// 권장: 함수와 전역 설정 변수로 처리
inline void LogDebug(const std::string& msg) {
    #ifdef DEBUG
    std::cout << "DEBUG: " << msg << std::endl;
    #endif
}
// 예: 프로젝트 접두사를 사용하여 매크로의 고유성 확보
#define MYPROJECT_DEBUG_LOG(msg) std::cout << "DEBUG: " << msg << std::endl;

// 사용 후 바로 해제
MYPROJECT_DEBUG_LOG("Starting process...");
#undef MYPROJECT_DEBUG_LOG

매크로 사용 지침

  • .h 파일에서 매크로 정의 금지
    • 매크로는 가능한 소스 파일(.cpp)에서 정의하고, 사용 직전에 정의한 후 사용 직후 #undef로 해제한다.
  • 기존 매크로 재정의 피하기
    • 이미 정의된 매크로를 #undef하고 재정의하지 말고, 고유한 이름을 사용하여 충돌을 피한다.
  • 불균형 C++ 구조 확장 피하기
    • 매크로가 불균형한 구조로 확장되는 것을 피하거나, 불가피할 경우 이에 대한 충분한 설명을 달아놓는다.
  • ## 사용 자제
    • 함수, 클래스, 변수 이름을 생성하기 위해 ## 연산자를 사용하는 것을 피한다.
  • 헤더에서 매크로 내보내기 금지
    • 헤더에서 매크로를 정의하고 해제하지 않은 채 내보내지 않도록 한다. 만약 반드시 필요할 경우, 프로젝트 네임스페이스(대문자) 접두어를 사용하여 고유한 이름을 부여한다.

요약

매크로는 C++에서 반드시 필요한 경우가 아니라면 피해야 하며, 유지보수와 가독성을 위해 대안을 사용하는 것이 권장된다. 매크로가 필요한 경우 헤더에서의 내보내기를 피하고, 고유한 이름을 사용하여 충돌을 방지한다.

7.15 0과 nullptr/NULL 사용 지침 (0 and nullptr/NULL)

 

  • 포인터에는 nullptr 사용
    • 포인터를 나타낼 때는 nullptr를 사용하여 타입 안전성을 확보한다. nullptr는 포인터 타입으로만 사용할 수 있기 때문에 코드의 혼동을 줄여준다.
  • 문자형에는 '\0' 사용
    • 문자형 데이터에서 null 문자를 나타낼 때는 0 대신 '\0'을 사용한다. 이는 해당 값이 문자임을 명확히 하고, 코드 가독성을 높이는 데 도움이 된다.
  • 정수형에는 0 사용
    • 정수형 데이터의 초기화에는 0을 사용하여 명확하게 구분한다.
int* ptr = nullptr;        // 포인터는 nullptr 사용
char null_char = '\0';     // 문자형 null 값은 '\0' 사용
int count = 0;             // 정수형에는 0 사용

 

7.16 sizeof

 

  • sizeof(varname) 권장
    • 특정 변수를 대상으로 sizeof를 사용할 때는 sizeof(varname)를 사용하는 것이 좋다. 이렇게 하면 해당 변수의 타입이 나중에 변경되더라도 자동으로 크기가 업데이트된다.
  • sizeof(type) 예외적 사용
    • 특정 변수와 무관하게, 외부 또는 내부 데이터 형식을 다루는 코드에서 특정 타입의 크기를 명시해야 할 때는 sizeof(type)를 사용한다. 예를 들어, 압축된 데이터를 다루는 로직에서 최소 크기를 보장하기 위해 sizeof(int) 등을 사용하는 경우가 이에 해당된다.

예시

MyStruct data;

// 특정 변수를 초기화할 때 변수명을 사용하는 것이 좋음
memset(&data, 0, sizeof(data)); 

// 특정 타입의 크기를 명시적으로 요구하는 경우에만 type 사용
if (raw_size < sizeof(int)) {
    LOG(ERROR) << "compressed record not big enough for count: " << raw_size;
    return false;
}

이와 같이 sizeof(varname)와 sizeof(type)를 구분하여 코드의 유지보수성을 높이는 것이 바람직하다.

7.17 타입 추론 (Type Deduction) - auto 포함

타입 추론은 코드가 프로젝트를 잘 모르는 독자에게도 더 명확하거나 안전하게 보일 때만 사용하는 것이 좋다. 단순히 명시적인 타입 작성을 피하기 위해 사용해서는 안 된다.

C++에는 컴파일러가 타입을 추론하거나, 코드에서 명시되지 않은 경우에도 사용할 수 있는 몇 가지 상황이 있다.

함수 템플릿 인수 추론(링크)

함수 템플릿은 명시적 템플릿 인수 없이 호출될 수 있으며, 컴파일러는 함수 인수의 타입을 통해 인수를 추론한다.

template <typename T>
void f(T t);

f(0);  // f<int>(0) 호출

auto 변수 선언(링크)

변수 선언에서 타입 대신 auto 키워드를 사용할 수 있으며, 컴파일러는 변수의 초기화 값으로부터 타입을 추론한다.

auto a = 42;  // a는 int
auto& b = a;  // b는 int&
auto d{42};   // d는 int, std::initializer_list<int> 아님

auto는 const로 한정할 수 있으며 포인터나 참조 타입으로도 사용할 수 있다. C++17 이후에는 decltype(auto)를 사용할 수 있으며, 초기화 값에 decltype(링크)을 적용한 결과로 타입을 추론한다.

함수 반환 타입 추론(링크)

auto와 decltype(auto)를 함수 반환 타입으로 사용할 수 있다. 컴파일러는 함수 본문에서 반환되는 구문을 기반으로 반환 타입을 추론한다.

auto f() { return 0; }  // f의 반환 타입은 int

제네릭 람다(링크)

람다 표현식에서 하나 이상의 매개변수 타입을 auto로 사용할 수 있다. 이렇게 하면 람다의 호출 연산자는 템플릿 함수가 되며, 각 auto 매개변수는 개별 템플릿 인수가 된다.

std::sort(vec.begin(), vec.end(), [](auto lhs, auto rhs) { return lhs > rhs; });

람다 초기화 캡처(링크)

람다 캡처는 명시적 초기화가 가능하여 기존 변수 캡처가 아닌 새로운 변수를 선언할 수 있다.

[x = 42, y = "foo"] { ... }  // x는 int, y는 const char*

클래스 템플릿 인수 추론(링크)

이 섹션은 별도로 다루어지므로 생략한다.(링크)

구조화된 바인딩(링크)

튜플, 구조체, 배열 선언에서 auto를 사용해 개별 요소의 이름을 지정할 수 있으며, 이를 구조화된 바인딩 선언이라 부른다.

auto [iter, success] = my_map.insert({key, value});
if (!success) {
  iter->second = value;
}

auto는 const, &, &&와 함께 사용할 수 있으며, 각 요소가 참조처럼 동작할 수 있다.

타입 추론의 중요성

타입 이름이 코드에서 반복될 때마다 읽기 어려울 수 있다. 따라서 안전성을 높이기 위해 타입을 추론할 수 있는 경우가 있으며, 예기치 않은 복사나 타입 변환을 피할 수 있다.

그러나 C++ 코드에서는 특히 코드의 먼 부분에서 타입 추론이 의존할 경우 명시적인 타입이 더 명확한 경우가 많다. 예:

auto foo = x.add_foo();
auto i = y.Find(key);

여기에서 y가 선언된 지점이 멀거나 잘 알려지지 않은 경우, 타입이 명확하지 않을 수 있다.

기본 규칙: 타입 추론은 코드가 더 명확하거나 안전하게 보일 때만 사용하고, 단순히 명시적 타입 작성의 불편함을 피하기 위해 사용하지 않는다.

함수 템플릿 인수 추론

함수 템플릿 인수 추론은 거의 항상 허용된다. 타입 추론은 함수 템플릿과 상호작용할 때 기대되는 기본 방식이며, 이를 통해 함수 템플릿은 무한한 세트의 일반 함수 오버로드처럼 작동할 수 있다. 따라서, 함수 템플릿은 보통 템플릿 인수 추론이 명확하고 안전하도록 설계되거나, 그렇지 않으면 컴파일되지 않도록 설계된다.

로컬 변수 타입 추론

로컬 변수의 경우, auto를 사용하여 명백하거나 중요하지 않은 타입 정보를 생략하고, 독자가 중요한 코드 부분에 집중할 수 있도록 코드 가독성을 높일 수 있다.

예시)

std::unique_ptr<WidgetWithBellsAndWhistles> widget = std::make_unique<WidgetWithBellsAndWhistles>(arg1, arg2);
absl::flat_hash_map<std::string, std::unique_ptr<WidgetWithBellsAndWhistles>>::const_iterator it = my_map_.find(key);
std::array<int, 6> numbers = {4, 8, 15, 16, 23, 42};

// 위의 코드에서 auto를 사용하여 아래와 같이 단순화할 수 있다.
auto widget = std::make_unique<WidgetWithBellsAndWhistles>(arg1, arg2);
auto it = my_map_.find(key);
std::array numbers = {4, 8, 15, 16, 23, 42};

타입이 반복적으로 포함된 경우, 특히 타입의 정보가 명확할 때 auto를 사용하여 보일러플레이트를 줄일 수 있다.

if (auto it = my_map_.find(key); it != my_map_.end()) {
  WidgetWithBellsAndWhistles& widget = *it->second;
  // `widget`과 관련된 작업 수행
}

템플릿 인스턴스의 경우, 매개변수가 보일러플레이트에 불과하고 템플릿 자체가 정보 전달에 중요한 경우 auto를 통해 타입을 간략하게 표현할 수 있다.

decltype(auto) 사용 지양

decltype(auto)는 간단한 옵션으로 대체할 수 있는 경우 사용하지 않는 것이 좋다. 이는 다소 생소한 기능이므로 코드의 가독성을 높이는 데에 비용이 많이 들 수 있다.

반환 타입 추론

함수와 람다의 반환 타입 추론은 함수 본문이 짧고 반환 구문이 매우 적은 경우에만 사용한다. 그렇지 않으면 독자가 반환 타입을 직관적으로 파악하기 어려울 수 있다. 또한, 반환 타입 추론은 인터페이스 대신 구현이 인터페이스를 정의하게 되므로 범위가 좁은 함수에서만 사용해야 한다. 특히, 헤더 파일에 있는 공개 함수에는 거의 사용하지 말아야 한다.

매개변수 타입 추론

람다의 auto 매개변수 타입은 주의하여 사용해야 한다. 실제 타입은 람다를 호출하는 코드에 의해 결정되므로, 명시적 타입이 훨씬 명확할 가능성이 높다. 람다가 정의된 곳 바로 옆에서 호출되는 경우가 아니라면, 명시적인 타입이 더 좋다.

람다 초기화 캡처

람다 초기화 캡처는 일반적인 타입 추론 규칙보다 더 구체적인 스타일 규칙에 의해 다뤄진다.

구조화된 바인딩

구조화된 바인딩은 독자에게 유용한 추가 정보를 제공할 수 있다. 즉, auto를 사용해도 명시적 타입이 필요하지 않은 경우가 생기며, 이는 주로 쌍이나 튜플일 때 유용하다. 이러한 구조화된 바인딩 선언은 객체가 특정 필드를 가진 구조체라면 더 나은 가독성을 제공할 수 있다.

예를 들어, API에서 강제로 튜플이나 쌍을 사용해야 할 때 유용하다.

auto [iter, success] = my_map.insert({key, value});
if (!success) {
  iter->second = value;
}

필요 시, 특정 필드 이름과 매핑하여 주석을 남겨 다른 필드와 혼동되지 않도록 하는 것이 좋다.

auto [/*field_name1=*/bound_name1, /*field_name2=*/bound_name2] = ...

위의 규칙에 따라 독자가 코드를 읽을 때 타입 정보를 혼동하지 않고, 명확하게 코드를 이해할 수 있도록 하는 것이 타입 추론의 목적이다.

7.18 클래스 템플릿 인수 추론 (Class Template Argument Deduction)

템플릿이 클래스 템플릿 인수 추론(CTAD)을 명시적으로 지원하도록 설정된 경우에만 CTAD를 사용해야 한다.

CTAD는 변수를 템플릿을 사용하는 타입으로 선언하되 템플릿 인수 리스트를 제공하지 않을 때 발생한다.

예시)

std::array a = {1, 2, 3};  // `a`는 std::array<int, 3> 타입이다.

이 경우, 컴파일러는 초기값으로부터 인수를 유추하고, 이는 템플릿의 '추론 가이드(deduction guide)'에 의존한다. 추론 가이드는 명시적일 수도 있고 암시적일 수도 있다.

추론 가이드의 정의

명시적 추론 가이드는 함수 선언과 유사하나, 함수명 대신 템플릿 이름을 사용한다. 예를 들어 위의 std::array 예시는 다음과 같은 추론 가이드를 사용한다.

namespace std {
template <class T, class... U>
array(T, U...) -> std::array<T, 1 + sizeof...(U)>;
}

기본 템플릿의 생성자 또한 암시적 추론 가이드를 정의한다. CTAD를 사용하는 변수 선언 시, 컴파일러는 생성자 오버로드 해석 규칙에 따라 추론 가이드를 선택하며, 해당 가이드의 반환 타입이 변수의 타입이 된다.

CTAD의 장단점

CTAD는 코드에서 반복적인 템플릿 인수 생략을 가능하게 해준다. 그러나, 생성자로부터 자동 생성되는 암시적 추론 가이드가 예기치 않은 동작을 일으키거나 잘못된 결과를 제공할 수 있다. 이는 특히 C++17 이전에 작성된 생성자에서 문제가 될 수 있다. 이러한 생성자는 CTAD에 대한 고려 없이 작성되었기 때문이다. 문제를 해결하기 위해 명시적 추론 가이드를 추가하면 기존 코드와의 호환성 문제가 발생할 수도 있다.

또한, CTAD는 auto와 유사한 단점도 가지고 있다. 변수의 타입이 초기값으로부터 추론된다는 점에서 정보가 생략되었음을 명확히 알리기 어렵다.

CTAD 사용 지침

특정 템플릿에 대해 CTAD를 사용하려면, 해당 템플릿의 유지관리자가 최소 하나의 명시적 추론 가이드를 제공해 CTAD 사용을 허용해야 한다. 표준 라이브러리 std 네임스페이스 내 모든 템플릿은 CTAD를 지원하는 것으로 간주된다. 가능한 경우, 이를 강제하는 컴파일러 경고를 설정하는 것이 좋다.

마지막으로, CTAD 사용 시 일반적인 타입 추론 규칙을 준수해야 한다.

7.19 지정된 초기화자 (Designated Initializers)

지정된 초기화자는 C++20에 도입된 문법으로, 구조체의 특정 필드를 이름을 명시하여 초기화하는 방식이다. 지정된 초기화자는 필드 순서가 복잡한 구조체를 직관적으로 초기화할 때 특히 유용하다.

예시)

struct Point {
  float x = 0.0;
  float y = 0.0;
  float z = 0.0;
};

Point p = {
  .x = 1.0,
  .y = 2.0,
  // z는 0.0으로 초기화됨
};

명시적으로 지정된 필드는 초기화되고, 나머지 필드는 Point{1.0, 2.0} 같은 전통적인 초기화 방식과 동일하게 초기화된다.

사용 시 주의사항

C 표준에서는 오래전부터 지원되었으나, C++에서는 C++20 이전까지 공식적으로 지원되지 않았으며 컴파일러의 확장으로 제공되었다. C++ 표준은 C보다 엄격한 규칙을 요구한다. 초기화자는 구조체 정의에 나온 필드 순서대로 나열해야 하며, 순서가 맞지 않으면 C++20 표준에 부합하지 않는다.

예를 들어, x를 먼저 초기화하고 그다음 z를 초기화하는 것은 허용되나, y와 x의 순서를 바꾸어 초기화하는 것은 허용되지 않는다.

규칙 요약

지정된 초기화자는 C++20 표준에 맞는 방식으로 사용해야 한다. 즉, 구조체 정의에 나열된 필드 순서대로 초기화자를 지정해야 한다.

7.20 람다 표현식 (Lambda Expressions)

람다 표현식은 익명 함수 객체를 간결하게 생성할 수 있는 방법이다. 특히 함수 인수로 함수를 전달할 때 유용하다. 예를 들어

std::sort(v.begin(), v.end(), [](int x, int y) {
  return Weight(x) < Weight(y);
});

람다는 외부 범위에서 변수를 명시적으로 이름으로 캡처하거나, 기본 캡처(default capture)를 사용해 암시적으로 캡처할 수 있다. 명시적 캡처는 각각의 변수를 값 또는 참조로 캡처하도록 명시한다.

int weight = 3;
int sum = 0;
// `weight`는 값으로, `sum`은 참조로 캡처
std::for_each(v.begin(), v.end(), [weight, &sum](int x) {
  sum += weight * x;
});

기본 캡처를 사용하면 람다 바디에서 참조되는 모든 변수를 자동으로 캡처할 수 있으며, 비정적 멤버를 사용하는 경우 this도 캡처된다.

const std::vector<int> lookup_table = ...;
std::vector<int> indices = ...;
// `lookup_table`을 참조로 캡처하여 `indices`를 정렬
std::sort(indices.begin(), indices.end(), [&](int a, int b) {
  return lookup_table[a] < lookup_table[b];
});

변수 캡처는 명시적 초기화자를 가질 수 있으며, 이동 전용(move-only) 변수를 값으로 캡처하거나 일반 참조 또는 값 캡처로 해결할 수 없는 상황에 유용하다.

std::unique_ptr<Foo> foo = ...;
[foo = std::move(foo)] () {
  ...
};

이와 같은 캡처 방식은 "초기화 캡처(init captures)" 또는 "일반화된 람다 캡처(generalized lambda captures)"라고 하며, 이는 반드시 외부 범위에서 변수를 캡처할 필요는 없다. 심지어 외부 범위의 이름을 가져오지 않아도 된다. 이러한 구문은 람다 객체의 멤버를 정의하는 완전한 방식이다.

[foo = std::vector<int>({1, 2, 3})] () {
  ...
};

초기화 캡처의 타입은 auto와 같은 규칙을 따라 추론된다.

람다 사용 시 이점과 주의사항

람다는 STL 알고리즘에 전달할 함수 객체를 정의하는 다른 방법들보다 훨씬 간결해 가독성을 높일 수 있다. 적절한 기본 캡처 사용은 코드의 중복을 줄이고 중요한 예외 사항을 강조할 수 있다. 또한 람다, std::function, std::bind를 결합하여 일반적인 콜백 메커니즘으로 사용할 수 있어, 바인딩된 함수를 인수로 전달하기 쉽게 만들어준다.

그러나 람다의 변수 캡처는 람다가 현재 범위를 벗어날 때 dangling-pointer(끊어진 포인터) 버그의 원인이 될 수 있다. 특히 기본 값 캡처는 깊은 복사를 수행하지 않으므로 참조 캡처와 같은 수명 문제를 가질 수 있다. this 포인터를 값으로 캡처할 때는 특히 주의가 필요하다.

캡처는 새로운 변수를 선언하지만, 다른 C++ 변수 선언 구문과는 다르다. 특히, 타입을 명시할 수 있는 자리나 auto 플레이스홀더도 제공되지 않아, 캡처가 선언이라는 사실을 인식하기 어려울 수 있다. 초기화 캡처는 타입 추론에 의존하므로 auto와 유사한 단점이 있으며, 구문 상에서 추론이 일어남을 독자에게 명확하게 전달하지 않는다.

람다 사용이 과도해질 경우, 지나치게 긴 중첩된 익명 함수는 코드 이해를 어렵게 만들 수 있다.

권장 사항

람다 표현식을 적절하게 사용하며, 다음과 같은 형식에 유의한다.

람다가 현재 범위를 벗어날 가능성이 있다면 명시적 캡처를 선호하자. 예를 들어

{
  Foo foo;
  ...
  executor->Schedule([&] { Frobnicate(foo); })
  ...
}
// 나쁨! - `foo`와 `this`가 참조로 캡처되며, 함수가 종료된 후 실행될 경우 문제가 발생할 수 있음

다음과 같이 명시적 캡처로 바꾸는 것이 좋다.

{
  Foo foo;
  ...
  executor->Schedule([&foo] { Frobnicate(foo); })
  ...
}
// 더 좋음 - `foo`가 위험하게 참조로 캡처된다는 것을 명확히 알 수 있음
  • 참조에 대한 기본 캡처([&])는 람다의 수명이 명확히 캡처 대상보다 짧을 때에만 사용한다.
  • 값에 대한 기본 캡처([=])는 짧고 명확하게 캡처된 변수가 명확할 때만 사용하며, 비정적 클래스 멤버에 접근할 때는 반드시 this를 명시적으로 캡처한다.
  • 캡처는 실제로 외부 범위에서 변수를 캡처하는 용도로만 사용한다. 초기화자를 통해 새로운 이름을 도입하거나 기존 이름의 의미를 변경하지 말고, 대신 새 변수를 선언하거나 함수 객체를 명시적으로 정의한다.

매개변수와 반환 타입에 대한 자세한 지침은 타입 추론 섹션을 참조한다.

7.21 템플릿 메타프로그래밍 (Template Metaprogramming)

복잡한 템플릿 프로그래밍은 피하는 것이 좋다.

템플릿 메타프로그래밍은 C++의 템플릿 인스턴스화 메커니즘이 튜링 완전하다는 사실을 활용하여 타입 도메인에서 임의의 컴파일타임 계산을 수행하는 일련의 기술을 의미한다. 이를 통해 매우 유연한 인터페이스를 제공할 수 있으며, 타입 안전성과 높은 성능을 보장한다. GoogleTest, std::tuple, std::function, Boost.Spirit 등과 같은 기능들은 템플릿 메타프로그래밍 없이는 구현이 불가능할 것이다.

그러나 템플릿 메타프로그래밍에 사용되는 기술은 언어 전문가가 아니면 이해하기 어려운 경우가 많다. 복잡하게 템플릿을 사용하는 코드는 가독성이 떨어지고, 디버깅이나 유지보수도 어렵다.

템플릿 메타프로그래밍은 매우 난해한 컴파일 타임 에러 메시지를 초래할 수 있다. 인터페이스는 단순하더라도 사용자가 잘못된 사용을 할 경우 복잡한 구현 세부 사항들이 노출된다. 또한 템플릿 메타프로그래밍은 리팩토링 도구들이 코드 구조를 자동으로 변환하기 어렵게 한다. 이는 템플릿 코드가 여러 문맥에서 확장되기 때문에 모든 문맥에서 변환이 타당한지 검증하기가 어렵기 때문이다.

템플릿 메타프로그래밍은 때때로 깔끔하고 사용하기 쉬운 인터페이스를 제공하는 데 유용할 수 있지만, 과도하게 복잡하게 작성할 유혹을 제공하기도 한다. 가능한 한 적은 수의 저수준 구성 요소에서만 사용하고, 그로 인해 발생하는 유지보수 부담을 분산시킬 필요가 있다.

템플릿 메타프로그래밍이나 기타 복잡한 템플릿 기법을 사용할 때에는 다시 한번 고려해 보라. 팀의 평균 구성원이 코드를 충분히 이해하여 유지할 수 있을지, 비전문가나 C++에 익숙하지 않은 사람이 에러 메시지를 이해할 수 있을지, 또는 호출하려는 함수의 흐름을 추적할 수 있을지를 생각해야 한다. 특히 재귀적인 템플릿 인스턴스화, 타입 리스트, 메타함수, 표현 템플릿(expression templates), SFINAE, sizeof 트릭을 이용한 함수 오버로드 판별에 의존하는 경우 지나치게 복잡한 코드가 될 가능성이 높다.

템플릿 메타프로그래밍을 사용해야 할 경우, 복잡성을 최소화하고 격리하기 위해 상당한 노력을 기울여야 한다. 사용자에게 노출되는 헤더 파일이 가독성을 유지할 수 있도록 메타프로그래밍을 구현 세부 사항으로 숨기고, 복잡한 코드에는 상세한 주석을 추가하는 것이 좋다. 코드가 어떻게 사용되는지 명확히 문서화하고, "생성된" 코드의 형태에 대해 설명해야 한다. 사용자 실수로 발생하는 컴파일러 에러 메시지를 특별히 주의 깊게 점검하고, 사용자 관점에서 이해하고 조치할 수 있도록 코드의 에러 메시지를 조정하는 것이 중요하다.

7.22 개념과 제약 (Concepts and Constraints)

개념(Concepts)을 필요할 때만 사용하라. 일반적으로 C++20 이전에 템플릿이 사용되던 상황에서만 개념과 제약을 사용해야 한다. 새 개념을 헤더에 도입할 때는 그 헤더가 라이브러리의 내부로 표시된 경우를 제외하고는 피하는 것이 좋다. 컴파일러가 강제하지 않는 개념은 정의하지 말고, 템플릿 메타프로그래밍보다는 제약을 우선시하라. template<Concept T> 구문 대신 requires(Concept<T>) 구문을 사용하라.

concept 키워드는 템플릿 매개변수에 대한 요구 사항(예: 타입 특성 또는 인터페이스 명세)을 정의하는 새로운 메커니즘이다. requires 키워드는 템플릿에 익명의 제약을 두고, 컴파일 시점에 제약을 만족하는지 확인할 수 있는 수단을 제공한다. 개념과 제약은 종종 함께 사용되지만, 독립적으로 사용할 수도 있다.

개념과 제약의 장점

  • 개념은 템플릿이 포함된 코드에서 훨씬 더 명확한 오류 메시지를 생성하도록 하여 혼란을 줄이고 개발 경험을 크게 개선할 수 있다.
  • 개념은 컴파일 타임 제약을 정의하고 사용하는 데 필요한 반복적인 코드(보일러플레이트)를 줄여 결과 코드의 명확성을 높인다.
  • 제약은 템플릿 및 SFINAE 기법으로 달성하기 어려운 기능을 제공한다.

개념과 제약의 단점

  • 템플릿과 마찬가지로 개념은 코드 복잡성을 크게 증가시켜 이해하기 어려워질 수 있다.
  • 개념 구문은 사용 위치에서 클래스 타입과 유사하게 보이기 때문에 독자가 혼동할 수 있다.
  • API 경계에서 개념이 사용되면 코드 결합도가 높아지고, 경직성이 증가하며, 변화를 어렵게 만든다.
  • 개념과 제약은 함수 본문의 논리를 중복할 수 있어 코드의 중복과 유지 관리 비용을 증가시킬 수 있다.
  • 개념은 독립적인 명명된 엔터티로 여러 위치에서 사용할 수 있는데, 시간이 지나면서 실제 요구 사항과 분리될 수 있다.
  • 개념과 제약은 오버로드 결정에 새롭고 비직관적인 영향을 미칠 수 있다.
  • SFINAE처럼, 제약은 코드의 대규모 리팩터링을 어렵게 만든다.

표준 라이브러리의 기존 개념 활용

표준 라이브러리의 미리 정의된 개념은 등가의 타입 특성이 존재할 때 우선적으로 사용해야 한다. (예: C++20 이전에 std::is_integral_v를 사용했던 경우 C++20에서는 std::integral을 사용) 마찬가지로 현대적인 제약 구문(예: requires(Condition))을 선호하고, std::enable_if<Condition>과 같은 이전 템플릿 메타프로그래밍 구문이나 template<Concept T> 구문은 피하라.

개념 재구현 금지 및 제약 지침

기존 개념이나 특성을 수동으로 다시 구현하지 말라. 예를 들어 requires(std::default_initializable<T>)를 사용하고, requires(requires { T v; })와 같은 방법은 피하라.

새로운 개념 선언은 매우 드물어야 하며, 라이브러리 내부에만 정의되어야 하며 API 경계에서 노출되지 않도록 해야 한다. C++17의 기존 템플릿 대안이 사용될 수 없는 경우가 아니라면, 개념이나 제약을 사용하지 말라.

또한, 함수 본문을 중복하거나 함수 본문을 읽는 것만으로도 분명하게 드러나는 요구 사항을 개념으로 정의하지 말라. 예를 들어 아래와 같은 코드는 피해야 한다.

template <typename T>  // 좋지 않음 - 중복이며 거의 이점이 없음
concept Addable = std::copyable<T> && requires(T a, T b) { a + b; };

template <Addable T>
T Add(T x, T y, T z) { return x + y + z; }

대신, 특정 케이스에서 개념이 유의미한 개선(예: 복잡한 중첩 또는 비직관적 요구 사항의 오류 메시지)을 제공하는 경우가 아니라면, 코드에서 일반 템플릿을 그대로 유지하는 것이 좋다.

컴파일러에서 정적으로 검증할 수 있는 개념을 사용하라

개념은 컴파일러에서 정적으로 검증할 수 있어야 한다. 주로 의미적이거나 다른 방식으로 강제되지 않는 제약에서 이점이 발생하는 개념을 사용하지 말라. 컴파일 타임에 강제되지 않는 요구 사항은 주석, 어서션 또는 테스트와 같은 다른 메커니즘을 통해 적용하라.

7.23 C++20 modules

C++20 모듈을 사용하지 말라.

C++20에서는 헤더 파일의 텍스트 포함 방식을 대체하기 위한 새로운 언어 기능인 "모듈"을 도입한다. 이를 지원하기 위해 module, export, import라는 세 가지 새로운 키워드가 추가되었다.

모듈은 C++ 작성 및 컴파일 방식에 큰 변화를 가져오며, Google의 C++ 생태계에서 모듈이 어떻게 적합하게 사용할 수 있을지에 대해 여전히 평가 중이다. 또한 현재 Google의 빌드 시스템, 컴파일러, 기타 도구에서는 모듈을 잘 지원하지 않으며, 이를 작성하고 사용하는 최선의 방안을 탐구할 필요가 있다.

7.24 Coroutines

코루틴을 사용하지 말라 (아직은).

<coroutine> 헤더를 포함하거나 co_await, co_yield, co_return 키워드를 사용하지 말라.

참고: 이 제한은 현재 일시적으로 적용된 것으로, 추가적인 사용 가이드라인이 마련될 때까지 유지될 예정이다.

7.25 Boost

Boost 라이브러리 모음 중 승인된 라이브러리만 사용하라.

Boost 라이브러리 모음은 동료 검토를 거친 무료 오픈소스 C++ 라이브러리의 모음으로, C++ 표준 라이브러리의 중요한 공백을 메우며, 대부분의 코드가 높은 품질과 넓은 호환성을 갖추고 있다. 하지만 Boost 라이브러리의 일부는 메타프로그래밍 및 고급 템플릿 기법과 지나치게 "함수형" 스타일의 프로그래밍을 장려하여 코드 가독성을 저해할 수 있다.

모든 기여자가 코드를 읽고 유지 보수할 수 있는 높은 수준의 가독성을 유지하기 위해, 승인된 일부 Boost 라이브러리만 사용해야 한다. 현재 사용이 허가된 라이브러리는 다음과 같다:

이 외에도 추가적인 Boost 라이브러리를 승인 목록에 추가하는 것을 검토하고 있으므로, 앞으로 목록이 확장될 수 있다.

7.26 허용되지 않는 표준 라이브러리 기능 (Disallowed Standard Library Features)

Boost와 마찬가지로, 일부 현대 C++ 라이브러리 기능은 코드 가독성을 저해하는 코딩 방식을 장려할 수 있다. 예를 들어, 타입 이름과 같은 검증된 중복을 제거해 가독성을 떨어뜨리거나, 템플릿 메타프로그래밍을 장려하는 기능이 그러하다. 또한, 기존 메커니즘을 중복하는 확장은 혼란과 전환 비용을 초래할 수 있다.

다음 C++ 표준 라이브러리 기능은 사용이 금지된다.

  • 컴파일 타임 유리수(<ratio>)
    • 템플릿 중심의 인터페이스 스타일을 촉진할 우려가 있어 사용이 제한된다.
  • <cfenv> 및 <fenv.h> 헤더
    • 많은 컴파일러에서 해당 기능을 안정적으로 지원하지 않기 때문에 사용이 금지된다.
  • <filesystem> 헤더
    • 테스트 지원이 충분하지 않고, 본질적으로 보안 취약점이 포함되어 있어 사용이 제한된다.

7.27 비표준 확장 기능 (Nonstandard Extensions)

별도로 지정되지 않은 한, C++의 비표준 확장 기능은 사용하지 말아야 한다.

컴파일러는 표준 C++에 포함되지 않은 다양한 확장 기능을 지원한다. 이러한 확장 기능에는 GCC의 __attribute__, __builtin_prefetch 또는 SIMD 같은 내장 함수, #pragma, 인라인 어셈블리, __COUNTER__, __PRETTY_FUNCTION__, 복합 문(statement expressions) (예: foo = ({ int x; Bar(&x); x });), 가변 길이 배열 및 alloca(), 그리고 "엘비스 연산자" a?:b 등이 포함된다.

비표준 확장은 표준 C++에 존재하지 않는 유용한 기능을 제공할 수 있다. 컴파일러에 중요한 성능 지침을 제공하려면 확장이 필요할 수 있다. 비표준 확장은 모든 컴파일러에서 작동하지 않으며, 이를 사용하면 코드의 이식성이 저하된다. 목표 컴파일러들에서 모두 지원된다고 하더라도, 종종 명확하게 정의되지 않아 컴파일러 간에 미묘한 동작 차이가 발생할 수 있다. 비표준 확장을 사용하면 코드를 이해하기 위해 독자가 추가적인 언어 기능을 숙지해야 한다. 비표준 확장은 아키텍처 간 이식성을 위해 추가 작업이 필요하다.

비표준 확장은 사용하지 않는다. 단, 프로젝트 전체의 이식성 헤더에 의해 제공되는 이식성 래퍼가 비표준 확장을 통해 구현된 경우, 해당 래퍼는 사용할 수 있다.

7.28 별칭 (Aliases)

공개 별칭은 API 사용자를 위한 것이므로 명확하게 문서화해야 한다.

다음은 다른 엔티티에 대한 별칭을 생성하는 몇 가지 방법이다.

using Bar = Foo;
typedef Foo Bar;  // C++ 코드에서는 `using`을 선호한다.
using ::other_namespace::Foo;
using enum MyEnumType;  // MyEnumType의 모든 열거형을 별칭으로 만든다.

새 코드에서는 typedef보다 using을 선호하는데, 이는 C++의 다른 부분과 더 일관된 문법을 제공하고 템플릿과 함께 작동하기 때문이다.

다른 선언들과 마찬가지로 헤더 파일에 선언된 별칭은 해당 별칭이 함수 정의, 클래스의 비공개 부분, 명시적으로 표시된 내부 네임스페이스에 있지 않는 한, 그 헤더의 공개 API의 일부가 된다. 이러한 영역에 있거나 .cc 파일에 있는 별칭은 클라이언트 코드가 참조할 수 없으므로 구현 세부 사항으로 간주되며 이 규칙의 제약을 받지 않는다.

별칭은 긴 이름이나 복잡한 이름을 단순화하여 가독성을 높일 수 있다. 별칭은 API에서 반복적으로 사용되는 유형을 하나의 장소에서 지정하여, 나중에 해당 유형을 쉽게 변경할 수 있도록 하여 중복을 줄일 수 있다. 별칭을 클라이언트 코드가 참조할 수 있는 헤더에 배치하면, 헤더의 API에 포함되는 엔티티의 수가 증가하여 복잡성이 높아질 수 있다. 클라이언트는 의도하지 않은 공개 별칭의 세부 사항에 의존할 수 있어 변경이 어려울 수 있다. 구현에서만 사용하려는 공개 별칭을 생성하고, API나 유지 관리에 미치는 영향을 고려하지 않는 실수를 저지르기 쉽다. 별칭은 이름 충돌의 위험을 초래할 수 있다. 별칭은 익숙한 구조에 생소한 이름을 부여하여 가독성을 떨어뜨릴 수 있다. 타입 별칭은 명확하지 않은 API 계약을 만들 수 있으며, 별칭이 항상 동일한 타입으로 보장되는지, 동일한 API를 가지는지, 아니면 제한적인 방법으로만 사용 가능한지를 알기 어렵다.

구현에서의 타이핑을 줄이기 위해서만 별칭을 공개 API에 두지 말고, 클라이언트가 사용하도록 의도된 경우에만 그렇게 한다.

공개 별칭을 정의할 때, 현재 연결된 타입과 동일하게 유지될지 또는 더 제한된 호환성이 의도되는지 여부를 포함하여 새로운 이름의 의도를 문서화해야 한다. 이를 통해 사용자는 타입을 상호 대체 가능하게 취급할 수 있는지 또는 특정 규칙을 따라야 하는지를 알 수 있으며, 구현이 어느 정도의 자유도를 가지고 별칭을 변경할 수 있도록 할 수 있다.

네임스페이스 별칭을 공개 API에 두지 않는다. (네임스페이스 섹션 참조)

다음 예시는 별칭이 클라이언트 코드에서 사용될 의도를 문서화한 것이다.

namespace mynamespace {
// 필드 측정을 저장하는 데 사용된다. DataPoint는 Bar*에서 내부 유형으로 변경될 수 있다.
// 클라이언트 코드는 이를 불투명 포인터로 취급해야 한다.
using DataPoint = ::foo::Bar*;

// 측정 집합. 사용자 편의를 위한 별칭일 뿐이다.
using TimeSeries = std::unordered_set<DataPoint, std::hash<DataPoint>, DataPointComparator>;
}  // namespace mynamespace

다음 예시는 사용 의도가 명확하지 않으며, 절반은 클라이언트용이 아니다.

namespace mynamespace {
// 나쁨: 사용 방법이 명시되지 않았다.
using DataPoint = ::foo::Bar*;
using ::std::unordered_set;  // 나쁨: 로컬 편의를 위해 사용
using ::std::hash;           // 나쁨: 로컬 편의를 위해 사용
typedef unordered_set<DataPoint, hash<DataPoint>, DataPointComparator> TimeSeries;
}  // namespace mynamespace
그러나 함수 정의, 클래스의 비공개 섹션, 명시적으로 표시된 내부 네임스페이스 및 .cc 파일에서는 로컬 편의를 위한 별칭이 허용된다.
// .cc 파일 내에서
using ::foo::Bar;

7.29 Switch Statements

열거형 값을 조건으로 하지 않는 경우, switch 문에는 항상 default 케이스가 있어야 한다(열거형 값을 사용하는 경우, 컴파일러가 처리되지 않은 값을 경고해 줄 것이다). default 케이스가 절대로 실행되지 않아야 한다면, 이를 오류로 처리해야 한다. 예를 들어

switch (var) {
  case 0: {
    ...
    break;
  }
  case 1: {
    ...
    break;
  }
  default: {
    LOG(FATAL) << "Invalid value in switch statement: " << var;
  }
}

하나의 case 레이블에서 다른 case 레이블로의 "폴스루"는 [[fallthrough]]; 속성을 사용하여 주석 처리해야 한다. [[fallthrough]];는 다음 case 레이블로 폴스루가 발생하는 실행 지점에 위치해야 한다. 예외로는 코드가 없이 연속된 case 레이블이 있는 경우, 별도의 주석이 필요하지 않다.

switch (x) {
  case 41:  // 여기서는 주석이 필요 없다.
  case 43:
    if (dont_be_picky) {
      // 주석 대신 또는 함께 사용할 수 있다.
      [[fallthrough]];
    } else {
      CloseButNoCigar();
      break;
    }
  case 42:
    DoSomethingSpecial();
    [[fallthrough]];
  default:
    DoSomethingGeneric();
    break;
}

참고 사항

  • default 케이스는 switch 문의 처리 누락을 방지하는 역할을 하며, 예상치 못한 값이 들어올 경우 오류를 기록하고 프로그램을 종료하도록 처리할 수 있다.
  • [[fallthrough]]; 속성을 사용하여 폴스루를 명확히 표시하면 코드의 의도를 명확히 하고, 유지 보수를 용이하게 한다.
  • 연속된 case 레이블이 있을 경우 주석이 불필요하지만, 그렇지 않다면 항상 폴스루를 명시적으로 처리해야 한다.

참조URL

https://google.github.io/styleguide/cppguide.html

728x90