본문 바로가기

프로그래밍 언어/C++

range-based for 구문 이해하기

반응형

range-based for 구문

스펙 문서에서 range-based for 구문을 명시한 내용은 다음과 같고, 등가 구문을 연이어 언급한다. 이후로 등가 구문 중심으로 설명하고, 해당 등가 구문이 작동하면, range-based for 구문이 정상적으로 작동함을 알려준다. 실제 구현 컴파일러도 동일하게 등가 구문으로 range-based for 구문을 구현한다. 이러한 이유로 본 게시글도 등가 구문을 중심으로 설명한다.

for ( init-statementopt for-range-declaration : for-range-initializer ) statement
// 등가 구문
{
    init-statementopt
    auto &&range = for-range-initializer ;
    auto begin = begin-expr ;
    auto end = end-expr ;
    for ( ; begin != end ; ++begin ) {
        for-range-declaration = * begin ;
        statement
    }
}

등가 구문 이해하기

init-statementopt

우선 주목해서 봐야 할 내용은 init-statementopt 마지막에 세미콜론이 없다. 세미콜론이 없음으로 init-statementopt 전체를 생략하더 라도 구문상에 문제가 되지 않는다. init-statementopt를 작성할 때 세미콜론을 마지막에 추가함으로써 하나의 statement를 추가할 수 있도록 규약하고 있다. 미래에 다수의 statement를 허용할지는 아직 알 수 없다.

따라서 현 시점에 range-based for 내부의 괄호 안에 포함할 수 있는 최대 statement는 init-statementopt 구현에 따라 한개 또는 두개 statement를 구성할 수 있다.

표준 스펙에서 하나의 statement는 declaration 또는 expression 중 하나가 되어야 한다. 즉 declaration과 expression를 반반 섞을 수 없다. 초기화 구문은 expression이 아닌 declaration 일부로 분류한다.

init-statementopt 구문에서 declaration 구문을 사용해 도입한 이름은 자신이 사용된 range-based for 전체를 자신의 scope로 사용하기 때문에 range-based for 내부에서만 사용되고, 벗어나는 순간 소멸된다.

init-statementopt 구문에서 declaration 구문을 사용하는 예제를 만들어 보자.


첫 예제는 기본 예제로 많이 등장하는 range-for 구문에서만 작동하는 mutex 관련 예제다.

import std;

template<typename T>
class my_guard {
    std::lock_guard<T> guard_{};
public:
    my_guard(T& mtx) :guard_{ mtx } {
        std::println("lock enter:{}", typeid(T).name());
    }
    ~my_guard() {
        std::println("lock exit");
    }
};

int main(int argc, char* argv[]) {
    std::mutex lck{};

    for (
        my_guard _{lck}; // _ declaration
        int const val : {1, 2, 3}
    ) {
        std::println("{}", val);
    }

    return 0;
}
[출력 결과]
lock enter:class std::mutex
1
2
3
lock exit

두번째 예제 역시, init-statementopt 구문에서 declaration를 사용한 예제로, 함수 참조 타입 변수과 함께, 일반 변수 선언 구문을 하나의 statement로 만드는 예제다. 함수 타입은 참조나 포인터를 제외한 반환 타입이 declaration 구문의 type-specifier이고, 변수 역시, 참조나 포인터를 제외한 타입이 declaration 구문의 type-specifier다. 같은 type-specifier를 가진 경우, 하나의 statement로 묶어낼 수 있다.

import std;

int random(int const& val) { 
    using namespace std;
    random_device r{};

    default_random_engine e1(r());
    uniform_int_distribution<int> uniform_dist(1, 100);
    return uniform_dist(e1);
}

int main(int argc, char* argv[]) {
    for (
        int basis{ 10'000 },
            (&fn)(int const& val) = random
        ;
        int const val : {1, 2, 3}
    ) {
        std::println("{}", fn(val) + basis);
    }

    return 0;
}
[출력 결과]
10046
10016
10027

세번째 예제는 init-statementopt 구문에서 expression를 사용한 예제다.

import std;

int main(int argc, char* argv[]) {
    for (
        std::cout << "range-based for start..\n";
        auto i:{1,2,3}
        ) {
        std::println("{}", i);
    }
    return 0;
}
[출력 결과]
range-based for start..
1
2
3

auto &&range = for-range-initializer ; 구문

for-range-initializer는 expression이다.

range-based for 구문의 괄호 마지막 구문 즉, for-range-initializer는 declaration이 아닌 expression다. 즉 해당 구문에서 새로운 변수를 선언할 수 없고, 오직 expression 구문만 사용할 수 있다.

for-range-initializer는 non-direct initialization 구문으로 해석한다.

range 변수 선언 과정은 non-direct initialization 구문이다.

non-direct initialization 구문에 사용된 = 대신 전체를 { } 감싸면, direct initialization 구문이 된다. 역으로 direct initialization 구문을 감싼 { }를 제거하고 = 앞에 넣어주면 non-direct initialization 구문이 된다. 즉 구문적으로 상호 변환이 가능하다.

두 구문 형식을 해석하는 방법이 조금 다르다.
direct initialization 구문 해석 과정에서 가장 외부 { }를 제거한 expression를 initialization 입력으로 사용한다. 이러한 이유로 direct initialization 구문에서 오류가 발생하면 가장 외부에 {}를 하나 더 추가하면 컴파일 오류가 없어지곤 한다.
non-direct initialization 구문은 별도 스펙 문서상에 요구 조건이 없다면 = 이후에 입력을 그대로 해석한다.

for-range-initializer를 그대로 해석하면 콤마 연산자의 일부로 해석하기 때문에, 즉, {1, 2, 3} 구문을 그냥 1,2,3으로 해석한다. 이를 방지하기 위해 스펙 문서에서는 가상 괄호가 for-range-initializer 전체를 한번 더 감싼 구문으로 변경한 후 구문을 해석하도록 별도 요구 사항을 명시하고 있다. 따라서 프로그래머가 {1,2,3}를 입력하면 컴파일러는 {{1,2,3}} 구문으로 변경하고, 해석 단계에서는 외부 괄호를 제거한 {1,2,3}로 최종 해석한다.

스펙 문서에서, direct initialization 구문과 non-direct initialization 구문 사이에서 서로 다르게 작동하도록 명시한 내용이 있다.
예를 들어, auto 구문을 사용한 direct initialization 구문에서 initializer_list 형식 입력에 대해 추론을 금지하고 있는 반면, auto 구문을 사용한 non-direct initialization 구문에서는 intializer_list 형식 입력에서 intializer_list 타입을 추론하도록 허용하고 있다.
그 외에도 conversion 과정에서도 direct initialization 구문는 입력 타입들로부터 explicit conversion 요구로 해석하고, non-direct initialization 구문은 implicit conversion 요구로 해석하고, 입력 타입에서 목적 타입으로 직접 변환 구문도 후보 변환 함수로 검색한다.

import std;

int main(int argc, char* argv[]) {
    auto&& m1 = { 1,2,3 };        // OK
    std::println("{}", typeid(m1).name());
    //auto&& m2{ 1,2,3 };        //ERROR. 외부 괄호를 제거한 expression이 1,2,3인데 추론할 수 없다.
    //auto&& m3{ { 1,2,3} };    //OK. 외부 괄호를 제거한 expression이 {1,2,3}인데, direct initialization 구문에서 추론 금지

    auto&& m4{ (1,2,3) };        //OK. 외부 괄호를 제거한 expression이 (1,2,3): 콤마 연산자 해석 적용해 3만 남는다.
    std::println("{} value:{}", typeid(m4).name(), m4);
    return 0;
}
[출력 결과]
class std::initializer_list<int>
int value:3

for-range-initializer 구문 오류 여부는 auto &&range = for-range-initializer ; 구문으로 확인한다.
for-range-initializer 구문으로 unknow bound 배열 타입이나 incomplete type이 사용될 수 없다. 이러한 타입으로부터 begin 또는 end 변수를 계산할 수 없기 때문에.

for-range-initializer 구문에서 만들어진 임시 객체 생명 주기 관리

C++20까지 for-range-initializer 구문에서 생성된 임시 객체에 대한 생명 주기 관리 조항이 없고 대신 for-range-initializer 구문에서 임시 객체를 만들면, undefined behavior로 명시했다.
이 조항은 C++23에서 "임시 객체 생명 주기 확장" 조항에서 조금 개선된다. 컴파일러 구현 단계에서도 임시 객체에 대한 생명 관리는 꽤나 구현 과정이 복잡하고 힘들다. 이유인 즉, 함수 내부에서 만들어진 임시 객체인지 여부를 추적하기 위해서는 일차적으로 함수 내부 구현을 컴파일러가 살펴 볼 수 있어야 하는데, 이 과정이 일차적으로 순탄하지 않고, 어떤 변수가 여러 함수를 호출 과정에서 생성되는 경우라면, 많은 함수를 역으로 추적해야 한다. 이런 이유로 인해, 현 시점에 컴파일러가 쉽게 추적할 수 있는 범위 내에서 규약을 정의하고 있고, 개선해야 할 과제로 남아있다.

using T = std::list<int>;
const T& f1(const T& t) { return t; }
T g();
void foo() {
    for (auto e : f1(g())) {} // OK, lifetime of return value of g() extended
}

g() 함수를 호출할 때 임시 객체가 만들어진다. 만들어진 임시 객체를 const T& 파라미터를 갖는 f1 함수로 전달된 후, 함수 내부에서 임시 객체를 그대로 반환하고 있다. 이 경우, f1(g()) 구문은 const T& temp = f1(g()); 으로 치환될 수 있고, 코딩의 가장 외부에서 참조 변수가 참조하는 대상을 인지할 수 있다. 이 경우, 컴파일러는 g() 함수가 만든 임시 객체를 사용하는 range-based for 구문 전체 영역으로 생명 주기 확장을 적용한다. 컴파일러 입장에서 생성된 임시 객체가 하나밖에 없다는 점도 분석 과정을 쉽게 만든다.

using T = std::list<int>;
const T& f2(T t) { return t; }
T g();
void foo() {
    for (auto e : f2(g())) {} // undefined behavior
}

g() 함수를 호출할 때 임시 객체가 만들어진다. 만들어진 임시 객체를 T 파라미터를 갖는 f2 함수로 전달되고, 또 다시 새로운 임시 객체를 생성한다. 컴파일러 입장에서 하나 이상의 임시 객체를 존재함으로써 추적이 힘들어진다. 이 경우, 컴파일러는 더 이상 분석을 시도하지 않고 포기한다.

for-range-initializer는 constexpr를 decl-specifier로 사용할 수 있다.

import std;

int main(int argc, char* argv[]) {
    constexpr int data[]{ 1,2,3,4,5 };
    int v{ 0 };
    for (auto&& m : data) {
        v += m;
    }
    std::cout << v;
    return 0;
}
[출력 결과]
15

auto begin = begin-expr ; 구문과 auto end = end-expr ; 구문

    auto begin = begin-expr ;
    auto end = end-expr ;

서로 다른 statement에서 begin과 end를 declaration한다.

begin과 end에 대한 declaration이 서로 다른 statement를 사용하고 있다. 따라서 begin과 end의 타입은 서로 다른 타입이 될 수 있다. 이전 스펙에서는 하나 statement 구문에서 두 변수를 declaration함으로써, 같은 타입임을 명시했다. C++20에서 서로 다른 statement에서 두 변수를 declaration하고 있다. 이렇게 함으로써 range TS에 선언한 container 타입도 자연스럽게 range-based for 구문에 사용될 수 있게 되었다.

begin-expr과 end-expr

두 표현식은 range-based for 구문의 괄호 마지막 구문 즉, range로부터 추론한다. range 타입이 클래스이면 해당 변수의 ragne.begin()과 range.end()로부터 1차 추론을 시도한다. 1차 추론이 실패하면 2차 추론을 begin(range)과 end(range) 구문으로 추론한다. 2차 추론은 ADL(argument dependent lookup)를 사용할 수 있는 구문 구조다. 이름 검색 방식으로 ADL 검색만 사용하고, ordinary unqualified lookup를 수행하지 않는다. 따라서 scope 포함 관계를 따라 begin과 end 함수를 추적하면서 의도와 다르게 작동하는 것을 방지한다.

import std;

int main(int argc, char* argv[]) {
    int ar[] { 1, 2, 3, 4, 5 };
    for (int& x : ar)
        x *= 2;

    for (auto&& item : ar) {
        std::print("{} ", item);
    }
    return 0;
}
[출력 결과]
2 4 6 8 10
import std;

int main(int argc, char* argv[]) {    
    std::filesystem::directory_iterator iter{ "." };

    for (auto file : iter) {
        std::println("dir:{}, path:{} ",file.is_directory(), file.path().string());
    }
    return 0;
}
[출력 결과]
dir:false, path:.\ConsoleApplication1.cpp
dir:false, path:.\ConsoleApplication1.vcxproj
dir:false, path:.\ConsoleApplication1.vcxproj.filters
dir:false, path:.\ConsoleApplication1.vcxproj.user
dir:false, path:.\Ext.h
dir:false, path:.\FileName.cpp
dir:false, path:.\HelloModule_i.ixx
dir:false, path:.\HeloModule.ixx
dir:false, path:.\termino.hpp
dir:false, path:.\test.c
dir:true, path:.\x64

연산 과정은 iterator 기본 연산자를 사용한다.

    for ( ; begin != end ; ++begin ) {
        for-range-declaration = * begin ;
        statement
    }

iterator의 기본 연산자 !=, ++, *를 기반으로 반복문이 구성된다.

for-range-declaration = * begin ; 구문

for-range-declaration 구문은 declaration 구문으로 변환된다. * begin 구문을 초기화 구문으로 사용함으로 derefernce operator 반환 타입에 의존적인데, 일반적인 구현이 lvalue를 반환하도록 구현한다. 따라서 특별한 경우가 아니라면 auto&&와 같이 추론 타입이 아닌 구체 타입의 rvalue reference 타입을 delclaration 구문에 사용할 수 없다.

    for (int&& m : { 1,2,3 }) {
        // ERROR. 'initializing': cannot convert from 'const _Elem' to 'int &&'
    }
    for (auto&& m : { 1,2,3 }) { // auto&& m은 rvalue reference로 추론된다.
    }

그 특별한 경우를 예제 코딩으로 작성하면 아래와 같다. int&&와 같이 구체 타입을 사용함으로 추론되지 않음에도 에러가 발생하지 않는데, 이유는 opeator*() 함수의 반환 타입 구현에 힌트가 있다.

#include <iostream>

class A {
    int v_{};
public:
    A(int v) :v_{ v } {};

    struct B {
        int v_{};
        B(int v) :v_{ v } {};
        B operator++() { return { --v_ }; }
        int operator*() { return v_; }

        friend
        bool operator==(B& b, int) { return b.v_ == 0; }
    };

    B begin() { return v_; }
    int end() { return 0; }
};

int main(){
    A a{ 5 };
    for (int&& item : a) {
        std::cout << item <<std::endl;
    }
    return 0;
}
[출력 결과]
5
4
3
2
1

사용자 정의 range-based for 구문

range-based for 구문은 등가 구문으로 해석하기 때문에, 등가 구문에서 정상적으로 작동하는 타입을 정의하면 range-based for 구문에도 사용될 수 있다.

728x90
반응형