기술 노트/Google C++ Style Guide

Google C++ Style Guide(2024) - 3장 스코핑 (Scoping)

anothel 2024. 11. 6. 18:58

3장 스코핑 (Scoping)

3.1 네임스페이스 (Namespaces)

대부분의 코드에는 네임스페이스를 사용해야 하며, 네임스페이스 이름은 프로젝트 이름과 경로에 따라 고유해야 한다. using 지시문(e.g., using namespace foo)과 인라인 네임스페이스는 사용하지 않으며, 무명 네임스페이스는 내부 연결에서 다룬다.

네임스페이스는 글로벌 스코프를 독립적이고 이름이 지정된 스코프로 분할해 이름 충돌을 방지하는 역할을 한다. 예를 들어, 두 프로젝트가 모두 전역 범위에서 Foo라는 클래스를 정의할 경우, 컴파일 또는 런타임에 충돌할 수 있다. 그러나 각 프로젝트가 네임스페이스를 사용하여 project1::Foo와 project2::Foo로 구분한다면, 충돌을 방지하면서 각 프로젝트 내부에서는 Foo라는 짧은 이름을 그대로 사용할 수 있다.

인라인 네임스페이스

인라인 네임스페이스는 외부 네임스페이스와 동일한 스코프에서 이름을 참조할 수 있도록 한다. 예를 들어, 아래 코드에서 outer::inner::foo()와 outer::foo()는 동일하게 참조된다.

namespace outer {
inline namespace inner {
  void foo();
}  // namespace inner
}  // namespace outer

인라인 네임스페이스는 주로 ABI(애플리케이션 바이너리 인터페이스) 호환성을 위해 사용되며, 네임스페이스 내에서 정의된 이름들이 실제로 제한되지 않아 혼란을 줄 수 있다. 따라서 인라인 네임스페이스는 특별한 버전 관리 정책에서만 유용하다.

네임스페이스 사용 규칙

  • 네임스페이스 이름 규칙을 따르고, 여러 줄로 작성한 네임스페이스는 종료 시에 주석을 달아야 한다.
  • 네임스페이스는 포함 파일, gflags 정의/선언, 외부 네임스페이스 클래스의 전방 선언 이후 소스 파일 전체를 감싼다.

헤더 파일의 예시

namespace mynamespace {

// 모든 선언은 네임스페이스 범위 내에 있어야 한다.
class MyClass {
 public:
  ...
  void Foo();
};

}  // namespace mynamespace

소스 파일의 예시

namespace mynamespace {

// 함수의 정의는 네임스페이스 내 범위에서 이루어진다.
void MyClass::Foo() {
  ...
}

}  // namespace mynamespace

복잡한 .cc 파일 예시

#include "a.h"

ABSL_FLAG(bool, someflag, false, "a flag");

namespace mynamespace {

using ::foo::Bar;

...네임스페이스 mynamespace에 대한 코드...

}  // namespace mynamespace

프로토콜 메시지 코드의 네임스페이스 사용

프로토콜 메시지 코드를 네임스페이스에 포함하려면 .proto 파일의 package 지정자를 사용한다.

std 네임스페이스 내 선언 금지

표준 라이브러리 클래스의 전방 선언을 포함하여 namespace std 내에 선언하는 것은 이식성을 저해하므로 금지된다. 표준 라이브러리 엔티티를 선언하려면 적절한 헤더 파일을 포함한다.

using 지시문 금지

다음과 같은 using 지시문은 네임스페이스 오염을 유발하므로 허용되지 않는다.

// 금지됨 -- 네임스페이스 오염을 일으킴.
using namespace foo;

네임스페이스 별칭 사용 제한

네임스페이스 범위에서 네임스페이스 별칭을 사용하는 것은 명시적으로 내부용으로 표시된 네임스페이스 내에서만 허용된다. 헤더 파일에서 네임스페이스 범위에 별칭을 설정하면 해당 파일이 내보내는 공개 API의 일부가 되기 때문이다.

// .cc 파일에서 자주 사용하는 이름에 대한 접근을 줄임.
namespace baz = ::foo::bar::baz;
// .h 파일의 내부 전용으로 사용.
namespace librarian {
namespace internal {  // 내부용, API의 일부가 아님.
namespace sidetable = ::pipeline_diagnostics::sidetable;
}  // namespace internal

함수 내 로컬 별칭

네임스페이스 별칭은 함수 내에서 지역적으로 사용할 수 있다.

inline void my_inline_function() {
  namespace baz = ::foo::bar::baz;
  ...
}

인라인 네임스페이스 사용 금지

인라인 네임스페이스는 사용하지 않는다.

내부용 네임스페이스에 "internal" 이름 사용

내부 네임스페이스 이름에 "internal"을 포함하여 API 사용자들이 접근하지 않도록 명확히 표시할 수 있다.

using ::absl::container_internal::ImplementationDetail;

중첩 네임스페이스 작성 시 단일 줄 사용

새 코드에서는 단일 줄 중첩 네임스페이스가 선호되지만 필수 사항은 아니다.

3.2 내부 연결 (Internal Linkage)

.cc 파일에서 다른 파일에서 참조할 필요가 없는 정의는 무명 네임스페이스에 넣거나 static으로 선언하여 내부 연결을 부여한다. .h 파일에서는 이 두 가지 방식을 사용하지 않는다.

모든 선언은 무명 네임스페이스에 배치함으로써 내부 연결을 가질 수 있다. 함수와 변수는 static으로 선언해도 내부 연결을 부여할 수 있다. 내부 연결이란 선언된 항목이 다른 파일에서 접근할 수 없다는 것을 의미하며, 다른 파일에서 동일한 이름으로 선언하더라도 완전히 독립적인 엔티티로 취급된다.

다른 파일에서 참조되지 않는 모든 코드에는 .cc 파일 내에서 내부 연결을 사용하는 것이 권장된다. .h 파일에서 내부 연결을 사용하는 것은 지양해야 한다.

무명 네임스페이스는 이름이 있는 네임스페이스와 동일한 형식으로 작성하며, 종료 주석에는 네임스페이스 이름을 비워 둔다:

namespace {
...
}  // namespace

3.3 비멤버, 정적 멤버, 그리고 전역 함수 (Nonmember, Static Member, and Global Functions)

비멤버 함수는 네임스페이스에 배치하는 것이 좋으며, 완전히 전역 범위에 있는 함수는 거의 사용하지 않도록 한다. 정적 멤버들을 단순히 그룹화하기 위해 클래스를 사용하는 것은 피해야 하며, 클래스의 정적 메서드는 일반적으로 클래스의 인스턴스나 클래스의 정적 데이터와 밀접하게 관련되어야 한다.

비멤버 및 정적 멤버 함수는 특정 상황에서 유용할 수 있다. 비멤버 함수를 네임스페이스에 포함하면 전역 네임스페이스를 오염시키는 것을 방지할 수 있다.

비멤버 및 정적 멤버 함수가 새로운 클래스의 멤버로 더 적합한 경우도 있다. 특히 외부 자원에 접근하거나 상당한 의존성이 있는 경우에는 새로운 클래스로 묶는 것이 의미가 있을 수 있다.

클래스 인스턴스에 종속되지 않는 함수가 필요할 때, 이를 정적 멤버 함수나 비멤버 함수로 정의할 수 있다. 비멤버 함수는 외부 변수를 참조하지 않아야 하며, 거의 항상 네임스페이스에 존재해야 한다. 정적 멤버만을 그룹화하기 위해 클래스를 만들지 말아야 한다. 이는 단순히 이름에 공통 접두사를 붙이는 것과 다를 바 없으며, 대체로 불필요한 작업이다.

비멤버 함수를 정의했을 때 그 함수가 해당 .cc 파일 내에서만 필요하다면, 내부 연결을 사용하여 해당 범위로 제한하는 것이 좋다.

3.4 지역 변수 (Local Variables)

함수의 변수는 가능한 한 좁은 범위에 배치하고, 선언 시 초기화하는 것이 좋다.

C++에서는 함수 내 어디에서나 변수를 선언할 수 있으며, 선언을 가능한 한 좁은 범위에, 사용 직전 위치에 두는 것이 권장된다. 이렇게 하면 코드 독자가 변수의 선언 위치와 타입, 초기화 값을 쉽게 찾을 수 있다. 특히, 선언과 초기화를 분리하지 말고 초기화와 함께 선언하는 것이 좋다.

int i;
i = f();      // 좋지 않음 - 선언과 초기화가 분리됨.
int i = f();  // 좋음 - 선언 시 초기화됨.

비슷하게, 변수 선언은 가능한 사용 직전 또는 사용 가까운 위치에 두는 것이 좋다.

int jobs = NumJobs();
f(jobs);      // 좋음 - 선언이 사용 직전에 위치.

초기화가 필요한 std::vector 같은 컨테이너는 중괄호 초기화를 통해 바로 값을 할당하는 것을 권장한다.

std::vector<int> v = {1, 2};  // 좋음 - v가 바로 초기화됨.

제어문 내 변수 선언

if, while, for 문과 같은 제어문에서 필요한 변수는 해당 구문 내에서 선언하여 그 범위 내에 한정하는 것이 일반적이다.

while (const char* p = strchr(str, '/')) str = p + 1;

단, 한 가지 유의할 점은 객체가 범위에 들어갈 때마다 생성자가 호출되고 범위를 벗어날 때마다 소멸자가 호출된다는 점이다.

비효율적 구현 예시

for (int i = 0; i < 1000000; ++i) {
  Foo f;  // 생성자와 소멸자가 100만 번 호출됨.
  f.DoSomething(i);
}

이런 경우에는 반복문 밖에 변수를 선언해 생성자와 소멸자가 한 번씩만 호출되도록 하는 것이 효율적이다.

Foo f;  // 생성자와 소멸자가 한 번씩만 호출됨.
for (int i = 0; i < 1000000; ++i) {
  f.DoSomething(i);
}

이렇게 하면 반복문 내에서의 성능이 개선될 수 있다.

3.5 정적 변수 및 전역 변수 (Static and Global Variables)

정적 저장 지속 시간 객체는 소멸자가 없거나, 소멸자 가 아무 작업도 하지 않는 경우에만 허용된다. 공식적으로는 사용자 정의나 가상 소멸자 가 없고, 모든 기본 및 비정적 멤버가 소멸 가능해야 한다는 의미이다. 함수 내 로컬 정적 변수는 동적 초기화가 가능하지만, 클래스의 정적 멤버 변수나 네임스페이스 범위의 변수에서는 제한적으로만 허용된다.

기본 규칙

전역 변수는 단독으로 constexpr로 선언할 수 있다면 이 조건을 만족한다고 할 수 있다.

모든 객체는 저장 지속 시간과 연결된 생명주기를 가지며, 정적 저장 지속 시간 객체는 초기화 후 프로그램 종료 시까지 존재한다. 이러한 객체는 네임스페이스 범위(전역 변수), 클래스의 정적 데이터 멤버, 또는 static으로 선언된 함수 내 로컬 변수로 나타난다. 함수 로컬 정적 변수는 선언을 처음 통과할 때 초기화되며, 그 외의 정적 저장 지속 시간 객체는 프로그램 시작 시 초기화된다. 프로그램 종료 시에는 모든 정적 저장 지속 시간 객체가 파괴된다.

동적 초기화와 정적 초기화

정적 초기화는 상수 값 또는 모든 바이트가 0으로 설정된 값으로 객체를 초기화하는 반면, 동적 초기화는 그 이후에 추가 작업이 필요할 때 발생한다.

전역 및 정적 변수의 사용 주의점

동적 초기화를 사용하는 전역 및 정적 변수는 복잡성을 유발하여 오류 추적이 어려워질 수 있다. 번역 단위 간에 초기화 순서가 정해지지 않으며, 파괴 또한 초기화 순서의 역순으로 진행된다. 초기화 중에 다른 정적 저장 지속 시간 변수에 대한 참조가 발생할 경우, 객체의 수명 시작 전이나 종료 후에 접근하는 오류가 발생할 수 있다.

소멸자 관련 규칙

소멸자가 필요 없는 객체는 파괴 순서와 무관하지만, 그렇지 않을 경우 수명 종료 후에도 접근하는 위험이 있다. 따라서, 정적 저장 지속 시간 객체는 소멸자가 없는 경우에만 허용된다. constexpr로 표시된 변수는 모두 소멸자가 없는 것으로 간주된다.

const int kNum = 10;  // 허용

struct X { int n; };
const X kX[] = {{1}, {2}, {3}};  // 허용

void foo() {
  static const char* const kMessages[] = {"hello", "world"};  // 허용
}

// 허용: constexpr가 소멸자를 보장함.
constexpr std::array<int, 3> kArray = {1, 2, 3};

// 비허용: 소멸자가 필요한 경우
const std::string kFoo = "foo";

// 비허용: 참조라도 라이프타임 확장된 임시 객체는 제한 대상.
const std::string& kBar = StrCat("a", "b", "c");

void bar() {
  // 비허용: 소멸자가 필요한 경우
  static std::map<int, int> kData = {{1, 0}, {2, 0}, {3, 0}};
}

참조는 객체가 아니므로 파괴와 관련된 제약을 받지 않지만, 동적 초기화 제한은 여전히 적용된다.

초기화 결정

초기화의 복잡성은 단순히 클래스 생성자뿐만 아니라 초기화 표현식 평가도 고려해야 한다. constexpr로 초기화된 변수는 상수 초기화가 가능하며 항상 허용된다. 모든 비로컬 정적 저장 지속 시간 변수는 동적 초기화가 될 가능성이 있으므로 주의 깊게 검토해야 한다.

struct Foo { constexpr Foo(int) {} };

int n = 5;  // 허용, 5는 상수 표현식
Foo x(2);   // 허용, 생성자가 constexpr로 지정됨
Foo a[] = { Foo(1), Foo(2), Foo(3) };  // 허용

허용되지 않는 초기화 예시

time_t m = time(nullptr);  // 초기화 표현식이 상수 표현식 아님.
Foo y(f());                // 상수 표현식 아님.
Bar b;                     // Bar의 생성자가 constexpr가 아님.

일반적인 패턴

  • 글로벌 문자열
    • 문자열 상수에는 constexpr로 string_view나 문자 배열을 사용하는 것이 좋다.
  • 동적 컨테이너
    • 동적 컨테이너(std::map 등)는 비소멸성 소멸자를 가지고 있어 전역 또는 정적 변수로 사용이 금지된다. 대신 정수 배열이나 쌍 배열과 같은 간단한 구조체를 사용할 수 있다. 예를 들어 작은 컬렉션은 선형 검색이 충분히 효율적이다.
  • 스마트 포인터
    • 스마트 포인터는 소멸자에서 정리를 수행하므로 사용 금지이다.
  • 사용자 정의 타입
    • 자체 정의된 정적 데이터는 소멸자가 없는 경우에 한해 허용된다.

마지막으로, 함수 내에서 로컬 정적 포인터를 통해 동적으로 생성된 객체를 사용하고 삭제하지 않는 방법도 선택할 수 있다.

static const auto& impl = *new T(args...);

3.6 thread_local Variables

함수 외부에서 선언된 thread_local 변수는 반드시 컴파일 타임 상수로 초기화되어야 하며, 이를 보장하기 위해 constinit 속성을 사용해야 한다. 다른 쓰레드 전용 데이터 정의 방식보다 thread_local을 선호한다.

thread_local 변수는 다음과 같이 선언할 수 있다.

thread_local Foo foo = ...;

이 변수는 사실 여러 객체의 집합으로, 각기 다른 쓰레드가 접근할 때마다 서로 다른 객체에 접근한다. thread_local 변수는 많은 면에서 정적 저장소 기간 변수와 유사하다. 네임스페이스 범위, 함수 내부, 또는 정적 클래스 멤버로 선언 가능하지만, 일반 클래스 멤버로는 선언할 수 없다.

thread_local 변수의 인스턴스는 정적 변수처럼 초기화되지만, 프로그램 시작 시 한 번 초기화되는 것이 아니라 각 쓰레드마다 별도로 초기화된다. 따라서 함수 내부에서 선언된 thread_local 변수는 안전하지만, 다른 thread_local 변수는 정적 변수와 같은 초기화 순서 문제 및 그 이상의 문제를 겪을 수 있다.

thread_local 변수는 소멸 순서에 미묘한 문제가 있다. 쓰레드 종료 시 thread_local 변수는 초기화의 반대 순서로 소멸되며, 어떤 thread_local 변수의 소멸자가 이미 소멸된 다른 thread_local 변수를 참조하면 진단이 어려운 'use-after-free' 문제가 발생할 수 있다.

thread_local 데이터는 본질적으로 쓰레드별로 격리되어 있어 경쟁 상태로부터 안전하므로 동시 프로그래밍에 유용하다. 또한 thread_local은 표준이 지원하는 유일한 쓰레드 전용 데이터 생성 방법이다. 다만, thread_local 변수에 접근할 때 쓰레드 시작 또는 해당 쓰레드에서 처음 사용될 때 예측할 수 없는 양의 다른 코드가 실행될 수 있다. thread_local 변수는 사실상 글로벌 변수이므로, 쓰레드 안전성을 제외한 글로벌 변수의 모든 단점을 가지고 있다.

프로그램에서 실행 중인 쓰레드 수가 많을 경우, thread_local 변수는 최악의 경우 쓰레드 수에 비례해 메모리를 소비하며 메모리 소모가 상당할 수 있다. 또한, 데이터 멤버는 static으로 선언되지 않는 한 thread_local로 사용할 수 없다.

thread_local 변수가 복잡한 소멸자를 가질 경우 'use-after-free' 문제를 겪을 수 있으며, 특히 어떤 변수의 소멸자가 이미 소멸된 다른 thread_local 변수를 참조해서는 안 된다. 이는 강제하기 어려운 특성이다. 글로벌/정적 컨텍스트에서 'use-after-free' 문제를 회피하기 위해 소멸자를 생략하는 접근법은 thread_local에서는 적용되지 않는다. 글로벌 및 정적 변수는 프로그램 종료 시 수명이 끝나기 때문에 소멸자를 생략해도 OS가 메모리 및 리소스를 정리해주지만, thread_local 소멸자를 생략하면 프로그램 실행 중 종료된 쓰레드 수에 비례한 리소스 누수가 발생할 수 있다.

클래스나 네임스페이스 범위에서 선언된 thread_local 변수는 동적 초기화가 없는 컴파일 타임 상수로 초기화되어야 하며, 이를 보장하기 위해 constinit (혹은 드물게 constexpr) 속성을 사용해야 한다.

constinit thread_local Foo foo = ...;

함수 내부에서 선언된 thread_local 변수는 초기화에 대한 문제는 없지만, 쓰레드 종료 시 여전히 'use-after-free' 위험이 있다. 클래스 또는 네임스페이스 범위의 thread_local을 대체하려면, 함수 내부 thread_local을 다음과 같이 사용할 수 있다.

Foo& MyThreadLocalFoo() {
  thread_local Foo result = ComplicatedInitialization();
  return result;
}

쓰레드가 종료될 때마다 thread_local 변수는 소멸되므로, 소멸자가 다른 (이미 소멸된) thread_local 변수를 참조하지 않도록 주의해야 한다. 가능한 한 소멸 시 사용자 코드가 실행되지 않는 단순 타입이나, 다른 thread_local 변수를 참조할 가능성이 없는 타입을 사용하는 것이 좋다.

thread_local은 쓰레드 전용 데이터를 정의할 때 다른 메커니즘보다 선호된다.


참조URL

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

728x90