Abseil Tip 165 초기화 구문을 포함한 if와 switch 문 사용하기

Tip of the Week #165: 초기화 구문을 포함한 ifswitch 문 사용하기

작성자: Thomas Köppe
최초 작성일: 2019년 8월 17일
업데이트: 2020년 1월 17일
원문 링크: abseil.io/tips/165


조건부 흐름 제어를 사용하지 않는다면, 이 글을 읽지 않아도 좋습니다.


새로운 문법

C++17에서는 ifswitch 문에 초기화 구문을 포함하는 것을 허용합니다:

if (init; cond) { /* ... */ }
switch (init; cond) { /* ... */ }

이 문법을 사용하면 변수의 범위를 가능한 한 좁게 유지할 수 있습니다:

if (auto it = m.find("key"); it != m.end()) {
  return it->second;
} else {
  return absl::NotFoundError("Entry not found");
}

초기화 구문의 동작은 for 문과 동일하며, 세부사항은 아래에 설명됩니다.


이 문법이 유용한 경우

복잡성을 관리하는 가장 중요한 방법 중 하나는 복잡한 시스템을 상호작용하지 않는 지역적인 부분으로 나누는 것입니다. 이렇게 하면 각 부분을 개별적으로 이해할 수 있고, 다른 부분은 완전히 무시할 수 있습니다. C++에서 변수의 존재는 복잡성을 증가시키며, 스코프(scope)는 이러한 복잡성을 제한하는 데 중요한 역할을 합니다. 즉, 변수가 스코프에 존재하는 시간이 짧을수록, 독자가 그 변수를 기억해야 할 필요가 줄어듭니다.

따라서 독자의 주의를 덜 요구하려면 변수의 범위를 실제로 필요한 곳으로 제한하는 것이 중요합니다. 새로운 문법은 이를 위한 새로운 도구를 제공합니다. C++17 이전에 작성해야 했던 대체 코드를 새로운 문법과 비교해 보겠습니다.

대안 1: 범위를 제한하기 위해 추가적인 중괄호 사용

{
  auto it = m.find("key");
  if (it != m.end()) {
    return it->second;
  } else {
    return absl::NotFoundError("Entry not found");
  }
}

대안 2: 범위를 넓게 유지하며 변수 “누수” 허용

auto it = m.find("key");
if (it != m.end()) {
  return it->second;
} else {
  return absl::NotFoundError("Entry not found");
}

새로운 문법은 이러한 두 가지 접근 방식의 단점을 해결합니다. if 문과 변수의 스코프가 결합되어 있어, 코드 이동이나 복사-붙여넣기를 하더라도 변수의 의미가 변경되지 않습니다. 반면 기존 스타일에서는 외부 중괄호를 복사하지 않거나 변수를 복사하지 않으면 변수의 스코프가 변하거나 이름 충돌이 발생할 수 있었습니다.

변수 이름과 스코프의 크기

이러한 복잡성 문제는 변수 이름의 길이가 스코프 크기와 일치해야 한다는 일반적인 경험칙으로 이어집니다. 즉, 스코프가 길수록 변수 이름은 더 길어야 합니다(독자가 한참 뒤에서도 이해할 수 있도록). 반대로, 스코프가 짧으면 더 짧은 이름을 사용할 수 있습니다. 변수 이름이 누수되면 it1, it2와 같은 비효율적인 이름이 필요하거나, 변수에 재할당(auto it = m1.find(/* ... */); it = m2.find(/* ... */))하거나, 지나치게 긴 이름(auto database_index_iter = m.find(/* ... */))을 사용해야 하는 상황이 발생합니다.


세부사항: 스코프와 선언 영역

ifswitch 문에서 선택적으로 사용할 수 있는 초기화 구문은 for 문에서의 초기화 구문과 동일한 방식으로 동작합니다. 이는 본질적으로 초기화가 포함된 문법은 다음과 같은 형태로 변환됩니다:

간략화된 형태 변환된 형태
if (init; cond) BODY { init; if (cond) BODY }
switch (init; cond) BODY { init; switch (cond) BODY }
for (init; cond; incr) BODY { init; while (cond) { BODY; incr; } }

중요한 점은 초기화 구문에서 선언된 이름이 if 문의 else 블록에서도 유효하다는 것입니다.

단, 초기화 구문이 조건 및 본문과 동일한 스코프에 속한다는 점에서 약간의 차이가 있습니다. 이로 인해 모든 부분에서 변수 이름이 고유해야 하며, 동일한 이름의 변수가 이전에 선언되었다면 이를 가릴 수는 있습니다. 아래 예는 허용되지 않는 재선언 및 허용되는 가림(shadowing)을 보여줍니다:

int w;

if (int x, y, z; int y = g()) {   // 오류: y는 이미 초기화 구문에서 선언됨
  int x;                          // 오류: x는 이미 초기화 구문에서 선언됨
  int w;                          // 허용: 외부 변수 w를 가림
  {
    int x, y;                     // 허용: 중첩된 스코프에서 가림 가능
  }
} else {
  int z;                          // 오류: z는 이미 초기화 구문에서 선언됨
}

if (int w; int q = g()) {         // 허용: 외부 변수 w를 가림
  int q;                          // 오류: q는 조건에서 선언됨
  int w;                          // 오류: w는 초기화 구문에서 선언됨
}

구조화된 바인딩과의 상호작용

C++17에서는 구조화된 바인딩(structured binding) 기능도 도입되었습니다. 이는 튜플, 배열, 구조체와 같은 “분해 가능한” 값의 요소에 이름을 할당하는 메커니즘입니다. 예:
auto [iter, ins] = m.insert(/* ... */);

구조화된 바인딩은 if 문 초기화 구문과 잘 결합됩니다:

if (auto [iter, ins] = m.try_emplace(key, data); ins) {
  use(iter->second);
} else {
  LOG(ERROR) << "Key '" << key << "' already exists.";
}

또한, C++17에서 도입된 노드 핸들(node handle) 기능을 사용하여 맵이나 셋 간의 요소를 복사 없이 이동할 수 있습니다. 다음은 이 기능을 활용한 예입니다:

if (auto [iter, ins, node] = m2.insert(m1.extract(k)); ins) {
  LOG(INFO) << "Element with key '" << k << "' transferred successfully";
} else if (!node) {
  LOG(ERROR) << "Key '" << k << "' does not exist in first map.";
} else {
  LOG(ERROR) << "Key '" << k << "' already in m2; m2 unchanged; m1 changed.";
}

결론

if (init; cond)switch (init; cond) 문법은 해당 문 내에서만 사용되는 변수로, 외부에서 필요하지 않은 경우에 활용하세요. 이는 주변 코드를 간소화하며, 변수의 범위가 짧아지므로 더 짧은 이름을 사용할 수도 있습니다.