상세 컨텐츠

본문 제목

[C++ 11] 문자가 특정 문자 집합에 속하는지 우아하게 테스트하기

프로그래밍/테크닉

by ∫2tdt=t²+c 2020. 3. 30. 02:02

본문

고전적 방법으로 문자 집합 소속 여부 테스트하기

문자열 처리를 하다보면 빠질수 없는 작업이 어떤 문자가 특정 문자 집합에 속하는지 확인하는 것일 겁니다. 예를 들어 'b'가 A~F 범위에 속하는지, '간'이 가~깋 범위에 속하는지 비교하는 것처럼요. 일반적으로 이 작업은 if문을 몇번 써서 수행할 수 있습니다. 예를 들어 특정 문자가 A-F 범위에 속하는지를 확인하는 함수는 다음과 같이 작성할 수 있지요.

1
2
3
4
bool is_in_a_f(int c)
{
    return 'A' <= c && c <= 'F';
}

아주 간단한 작업입니다. 그런데 테스트해야하는 문자 집합이 복잡해지다보면 위 함수도 차츰 복잡해지게 되겠죠? 예를 들어 특정 문자가 A~F, a~f, 0~4, !, ? 중에 속하는지 확인해야 한다면 함수 내의 if문이 늘어나게 될 겁니다.

1
2
3
4
5
6
7
bool is_in_something(int c)
{
    if('A' <= c && c <= 'F') return true;
    if('a' <= c && c <= 'f') return true;
    if('0' <= c && c <= '4') return true;
    return c == '!' || c == '?';
}

비교문이 참 많습니다. 위 함수를 굉장히 많이 호출해야 한다면 어떨까요? 최선의 경우는 A~F 사이의 문자가 입력되어서 비교 2번만에 return true;로 탈출하는 것입니다. 만약 입력 문자가 '?'이거나 해당 집합에 속하지 않는 전혀 엉뚱한 문자라면 is_in_something 함수 내에서는 비교 연산이 총 8번 수행될 겁니다. 더 빠르게 할 수는 없을까요?

당연히 있습니다. 문자에 대한 룩업테이블(Lookup table, LUT)을 만들어서 특정 집합에 소속된 문자에는 1, 그렇지 않은 문자에는 0을 부여해주면 됩니다. 이러면 배열을 한 번 참조함으로써 비교 연산을 바로 끝낼 수 있지요.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class IsInSomething
{
    std::array<bool, 256> lut = {{false, }};
public:
    IsInSomething()
    {
        for(int i = 'A'; i <= 'F'; ++i) lut[i] = true;
        for(int i = 'a'; i <= 'f'; ++i) lut[i] = true;
        for(int i = '0'; i <= '4'; ++i) lut[i] = true;
        lut['!'] = true;
        lut['?'] = true;
    }
 
    bool operator()(int c) const
    {
        if(c < 0 || c >= 256) return false;
        return lut[c];
    }
};

룩업테이블을 관리하는 클래스를 만들었습니다. 생성시에 A~F, a~f, 0~4, !, ?에 해당하는 위치는 true로 설정하고 나머지는 모두 false로 설정합니다. 그리고 실제 문자 테스트시에는 배열을 참조하기만 하는 것이죠. 이러면 위의 함수보다 훨씬 빠르게 (대신 256바이트를 더 쓰게 됩니다.) 문자 비교가 가능하지요. 문자 비교를 굉장히 많이 실시해야하는 경우 유용하게 쓰일 수 있습니다.

그런데 이 방법은 귀찮습니다. 문자 집합별로 IsInSomething과 같은 클래스를 만들어줘야 하지요. 지금은 A~F, a~f, 0~4, !, ? 하나였지만, 이런 비교 세트가 수십개가 있다면 각각 저런 클래스를 만들어둬야합니다. 매우 단순한 작업인데 귀찮습니다. 이걸 자동화할 수 없을까요?

가변인자 템플릿을 이용해 문자 집합 소속 여부 테스트하기

C++11에서 도입된 가변인자 템플릿을 사용하면 이를 자동화할 수 있습니다! 기본적인 아이디어는 다음과 같습니다. 

  1. 문자 1개를 비교하는 템플릿 클래스, 문자 범위를 비교하는 템플릿 클래스를 정의한다

  2. 1에서 정의한 템플릿 클래스를 여러 개로 OR로 이을 수 있는 가변 인자 템플릿 클래스를 정의한다

  3. 1,2에서 정의한 템플릿 클래스로부터 LUT를 생성하여 비교에 사용할 수 있게하는 템플릿 클래스를 정의한다.

  4. PROFIT!!

간단하죠? 코드를 보면서 같이 나아가봅시다.

먼저 문자 1개를 비교하는 템플릿 클래스와 문자 범위를 비교하는 템플릿 클래스를 정의해봅시다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
template<int _chr>
class OneChar
{
public:
    enum { chr = _chr };
 
    bool operator()(int c) const
    {
        return c == chr;
    }
};
/*
위 템플릿 클래스는 다음과 같이 사용할 수 있습니다.
OneChar<'a'> is_a;
is_a('a'); // true
is_a('b'); // false
*/
 
template<int _front, int _back>
class CharRange
{
public:
    enum { front = _front, back = _back };
 
    bool operator()(int c) const
    {
        return front <= c && c <= back;
    }
};
/*
위 템플릿 클래스는 다음과 같이 사용할 수 있습니다.
CharRange<'a', 'f'> is_a_f;
is_a('a'); // true
is_a('g'); // false
*/

어머, 너무 간단해서 부연설명이 필요없을 정도입니다. 근데 각각의 템플릿 클래스는 말그대로 문자 1개, 혹은 문자 범위 하나만을 비교할 수 있으므로 좀더 복잡한 케이스를 비교하기엔 역부족입니다. 이를 위해서 OR 연산으로 이 클래스들을 이어줄 수 있는 가변인자 템플릿 클래스를 만들어봅시다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
template<typename... _Subsets>
class CharOr;
/*
기본 뼈대입니다. 가변인자인 _Subsets은 0개 이상의 타입을 받을 수 있습니다.
먼저 제일 단순한 경우인 인자가 0개인 경우부터 시작해봅시다.
*/
 
/*
인자가 0개인 경우에 대한 템플릿 특수화.
비교할게 없으므로 항상 false입니다.
*/
template<>
class CharOr<>
{
public:
    bool operator()(int c) const
    {
        return false;
    }
};
 
/*
인자가 1개 이상인 경우에 대한 템플릿 특수화.
1개 이상인 경우는 다음과 같이 첫번째 인자와 나머지 인자를 구분해서 받습니다.
*/
template<typename _Subset, typename ..._SubsetRest>
class CharOr<_Subset, _SubsetRest...>
{
public:
    bool operator()(int c) const
    {
        return _Subset{}(c) || CharOr<_SubsetRest...>{}(c);
// 먼저 첫번째 인자에 대해서 테스트를 실시하고
// 나머지 인자에 대한 테스트는 CharOr<_SubsetRest...>에게로 위임합니다.
    }
};
 
/*
이렇게 하면 재귀적으로 모든 경우에 대해서 처리가 가능하게 됩니다.
따라서 다음과 같이 사용이 가능하지요.
CharOr<OneChar<'a'>, OneChar<'b'>, CharRange<'0', '3'>> is_ab0_3;
is_ab0_3('a'); // true
is_ab0_3('b'); // true
is_ab0_3('1'); // true
is_ab0_3('4'); // false
*/

가변인자 템플릿의 핵심은 1)재귀 처리를 어떻게 정의하는지, 2) 종료 조건을 어떻게 설정하는지입니다. 위 경우는 N개의 인자를 받는 경우에 그 중 첫번째 인자에 대해서만 직접 비교를 수행하였고, 나머지 비교 연산은 나머지 N-1개의 인자를 가지는 템플릿 클래스에게로 넘겼습니다. 그러면 N-1개의 인자를 가지는 템플릿에서는 또 1개를 꺼내 비교를 수행하고, N-2개의 인자를 가지는 템플릿에게로 작업을 토스하겠죠? 이를 계속 반복하여 최종적으로는 0개의 인자를 가지는 템플릿으로 넘어오고 여기서 재귀가 종료됩니다. 

지금까지 만든 CharOr클래스는 그냥 비교 연산을 여러번 수행하여 문자가 특정 집합에 속하는지를 확인하는 함수를 자동으로 만들어주는 역할에 불과합니다. 앞서 이야기했듯이 이는 확인할 집합이 복잡해질수록 비교연산이 늘어나므로 복잡한 경우에는 LUT를 사용해 최적화하는게 필요합니다. 이것도 템플릿으로 자동화해봅시다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
/*
여기서부터는 조금 복잡해집니다.
일단 우리가 비교하고자 하는 문자 집합의 범위를 확실히 알 필요가 있습니다.
A~F, a~f 범위만 비교하려고 하는데 0~255까지의 배열 전부를 쓰는건 메모리 낭비니깐요.
따라서 위에서 정의한 템플릿 클래스들이 가리키는 문자집합의 범위를 구해주는
Trait 템플릿을 먼저 만들겁니다.
*/
 
template<typename _Ty>
struct CharTrait;
/*
CharTrait 클래스의 뼈대입니다.
_Ty로는 위에서 정의한 OneChar, CharRange, CharOr만 들어온다고 가정합시다.
*/
 
template<int _chr>
struct CharTrait<OneChar<_chr>>
{
    enum { lowest = _chr, highest = _chr, span = 1 };
};
/*
먼저 OneChar에 대한 Trait 특수화입니다.
문자 1개이므로 가장 낮은 위치의 문자도 _chr이고
가장 높은 위치의 문자도 _chr입니다.
그리고 그 범위의 크기는 1이구요.
 
예를 들어 CharTrait<OneChar<'a'>>::lowest는 'a'가 되고
CharTrait<OneChar<'a'>>::highest도 'a'가 되고
CharTrait<OneChar<'a'>>::span은 1이 되는 식입니다.
*/
 
template<int _front, int _back>
struct CharTrait<CharRange<_front, _back>>
{
    enum { lowest = _front, highest = _back, span = highest - lowest + 1 };
};
/*
그 다음은 CharRange에 대한 Trait 특수화입니다.
가장 낮은 위치의 문자는 _front이고
가장 높은 위치의 문자는 _back입니다.
그리고 그 범위의 크기는 _back - _front + 1이 되구요.
 
예를 들어 CharTrait<CharRange<'a', 'c'>>::lowest는 'a'가 되고
CharTrait<CharRange<'a', 'c'>>::highest는 'c'가 되고
CharTrait<CharRange<'a', 'c'>>::span은 3이 되는 식입니다.
*/
 
template<typename _Subset>
struct CharTrait<CharOr<_Subset>> : public CharTrait<_Subset>
{
};
/*
마지막으로 대망의 CharOr에 대한 Trait 특수화입니다.
CharOr는 가변인자 템플릿이므로, Trait 특수화 역시 1개인 경우와 여러 개인 경우를 나누어 정의해야합니다.
먼저 1개인 경우는 _Subset의 Trait을 그대로 상속받아 사용합니다.
즉, CharTrait<CharOr<OneChar<'a'>>>는 CharTrait<OneChar<'a'>>와 동일하게 처리됩니다.
다음은 2개 이상인 경우입니다.
*/
 
template<typename _Subset1, typename _Subset2, typename ... _SubsetRest>
struct CharTrait<CharOr<_Subset1, _Subset2, _SubsetRest...>>
{
private:
    enum { l1 = CharTrait<_Subset1>::lowest, h1 = CharTrait<_Subset1>::highest };
    enum { l2 = CharTrait<CharOr<_Subset2, _SubsetRest...>>::lowest, h2 = CharTrait<CharOr<_Subset2, _SubsetRest...>>::highest };
public:
    enum { lowest = l1 < l2 ? l1 : l2, highest = h1 < h2 ? h2 : h1, span = highest - lowest + 1 };
};
/*
2개 이상인 경우를 다루기 위해 템플릿 인자로 _Subset1, _Subset2, _SubsetRest를 도입했습니다.
이 Trait에서는 먼저 _Subset1의 lowest와 highest를 l1, h1이라는 이름으로 가지고 옵니다.
그리고 첫번째 인자를 제외한 나머지 인자들에 대해 Trait을 구해 lowest, highest를 l2, h2라는 이름으로 가지고 옵니다. (이 역시 재귀로 떠넘깁니다.)
그리고 최종적인 lowest는 두 l1, l2중 작은 값으로 최종적인 highest는 두 h1, h2중 큰 값으로 정의합니다. span은 highest - lowest + 1로 정의하구요.
 
예를 들어 CharTrait<CharOr<CharRange<'0', '3'>, CharRange<'5', '9'>>>의 lowest는 '0', highest는 '9', span은 10이 될겁니다.
*/

CharTrait을 도입하여 OneChar, CharRange, CharOr가 섞여 있는 경우 그 문자 범위의 시작 지점과 끝지점을 알 수 있도록 했습니다. 이제 이걸 이용해서 LUT를 만들어 내는 클래스를 짜봅시다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
/*
CharSet 클래스는 OneChar, CharRange, CharOr 클래스를 받아서 LUT를 생성해주는 역할을 수행합니다.
단, 문자 집합이 단순한 경우는 굳이 LUT를 만들필요가 없으므로, 일단은 LUT없이 그대로 호출을 떠넘기도록 해봅시다.
*/
template<typename _Ty>
class CharSet
{
public:
    bool operator()(int c) const
    {
        return _Ty{}(c);
// 이 클래스는 특별한 작업 없이 _Ty의 비교 함수를 그대로 가져다가 씁니다.
    }
};
 
/*
CharOr에 3개 이상의 인자가 붙은 경우는 충분히 복잡한 케이스라 보고
이 경우에는 LUT를 생성하도록 특수화해봅시다.
*/
template<typename _S1, typename _S2, typename _S3, typename ..._SRest>
class CharSet<CharOr<_S1, _S2, _S3, _SRest...>>
{
    using Trait = CharTrait<CharOr<_S1, _S2, _S3, _SRest...>>;
    std::array<bool, Trait::span> lut = {{ false, }};
// LUT는 Trait의 span길이만큼의 bool 배열로 정의합니다.
// 메모리 최적화를 하고 싶다면 std::bitset을 사용해도 되겠습니다.
public:
    CharSet()
    {
        detail::LUTSetter<_S1, _S2, _S3, _SRest...>{}(lut.data() - Trait::lowest);
// LUT를 초기화하는건 LUTSetter라는 헬퍼 클래스를 사용하도록 합시다.
// 이는 아래에서 정의하겠습니다.
    }
 
    bool operator()(int c) const
    {
        if (c < Trait::lowest
            || c > Trait::highest) return false;
// 입력 문자의 범위가 lowest~highest를 벗어나면 바로 false입니다.
        return lut[c - Trait::lowest];
// 그렇지 않으면 lut를 참조해 true/false를 테스트합니다.
    }
};
 
namespace detail
{
    template<typename ..._Subsets>
    struct LUTSetter;
/* LUT를 초기화하는 헬퍼 클래스의 뼈대입니다.
CharOr가 가변인자를 받듯이, LUTSetter도 동일합니다.*/
 
    template<>
    struct LUTSetter<>
    {
        void operator()(bool* arr) const
        {
        }
    };
/*
먼저 인자가 0개인 경우는 아무일도 하지 않습니다.
*/
 
 
    template<typename _Subset, typename... _SubsetRest>
    struct LUTSetter<_Subset, _SubsetRest...>
    {
        void operator()(bool* arr) const
        {
            for (int c = CharTrait<_Subset>::lowest; c <= CharTrait<_Subset>::highest; ++c)
            {
                arr[c] = true;
            }
 
            LUTSetter<_SubsetRest...>{}(arr);
        }
    };
/*
인자가 1개 이상인 경우는 먼저 첫번째 인자에 대해 LUT 테이블에 true 값을 써넣습니다.
그리고 나머지 인자들에 대해서는 재귀적으로 LUTSetter<_SubsetRest...>에 떠넘깁니다.
*/
}
 
/*
자 이로써 LUT를 생성하는 CharSet 클래스도 구현완료되었습니다.
이는 다음과 같이 쓸 수 있습니다.
CharSet<CharOr<CharRange<'A', 'F'>, CharRange<'a', 'f'>>> is_a_f;
is_a_f('a'); // true
is_a_f('A'); // true
is_a_f('0'); // false
*/

길고 긴 구현이 끝났습니다. 이제 CharSet<CharOr<CharRange<'A', 'F'>, CharRange<'a', 'f'>>> is_a_f; 와 같은 모양으로 A~F, a~f 범위에 속하는 문자를 테스트하는 LUT를 생성하고 비교하는 작업을 수행할 수 있습니다. 이는 템플릿으로 정의되므로 코드를 컴파일하는 과정에서 A~F, a~f에 대한 LUT 생성 코드가 풀어써지므로 런타임의 오버헤드를 줄일수 있다는 장점이 있습니다. 근데.. 와... 너무 지저분하다~

C++의 템플릿 문법은 강력하지만 가독성이 너무나도 나쁩니다. 저렇게 쓰라고 하면 아무도 안 쓰겠지요? 정규표현식을 쓰듯이 [A-Fa-f]처럼 간단하고 우아하게 쓸 수는 없을까요?

정규표현식과 유사하게 쓸 수 있도록 유틸리티 템플릿을 만들기

위의 지저분한 문법대신 "A-Fa-f"처럼 깔끔하게 써서 A~F, a~f 범위의 문자를 테스트할 수 있도록 개선하는 것도 템플릿으로 가능합니다. 아이디어는 템플릿 인자로 "A-Fa-f"를 받으면 이를 해석하여 CharSet<CharOr<CharRange<'A', 'F'>, CharRange<'a', 'f'>>> 타입으로 치환해주는 템플릿 클래스를 만들면 됩니다. 단, 현재 C++11에서 템플릿 인자로 문자열을 바로 받는게 안되므로, 가변인자 템플릿을 이용해 문자를 하나씩 하나씩 받도록 해야합니다.

이 역할을 수행하는 유틸리티 템플릿 클래스를 CharSetParser라고 해봅시다. 그러면 CharSetParser<'A', '-', 'F', 'a', '-', 'f'>와 같은 식으로 템플릿 인자로 입력이 들어가는 것이구요, 이 때 CharSetParser<'A', '-', 'F', 'a', '-', 'f'>::type이 CharSet<CharOr<CharRange<'A', 'F'>, CharRange<'a', 'f'>>>가 되도록 하는 것입니다. 구현하기 복잡해보이지만, 생각보다 복잡하지는 않아요!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
namespace detail
{
/* 먼저 유틸리티로 타입 2개를 CharOr로 연결해주는
연산용 템플릿 클래스를 만들어봅시다.
일반적인 경우 두 타입을 그대로 CharOr에 넣어주면 되겠죠.
*/
    template<typename _Ty1, typename _Ty2>
    struct OrConcat
    {
        using type = CharOr<_Ty1, _Ty2>;
    };
 
/* 단 Ty1과 CharOr<...>를 연결할때
CharOr<Ty1, CharOr<...>>처럼 CharOr이 여러개 들어가지 않고
CharOr<Ty1, ...>처럼 flatten 되도록 특수화를 실시해줍니다.
*/
    template<typename _Ty1, typename ..._Subsets>
    struct OrConcat<_Ty1, CharOr<_Subsets...>>
    {
        using type = CharOr<_Ty1, _Subsets...>;
    };
 
/* 실제 파서의 구현체가 될 가변인자 템플릿 클래스입니다.
_chrs는 각각의 문자 하나씩을 지칭합니다.
char타입 대신 int 타입을 쓴 이유는 유니코드도 대비해서.. */
    template<int ..._chrs>
    struct CharSetParserImpl;
 
/* 먼저 파서에 입력이 없는 경우입니다.
이 경우 타입은 아무것도 없는 CharOr<>타입이 되겠죠.*/
    template<>
    struct CharSetParserImpl<>
    {
        using type = CharOr<>;
    };
 
/* 그리고 문자 앞쪽에 0이 등장하는 경우의 특수화입니다.
c의 문자열 리터럴은 null 종료 문자열이지요?
따라서 0이 등장했다는 것은 문자열 해석을 끝마쳐야한다는 뜻입니다.
즉 이 경우 입력이 없는 경우와 동일하게 처리합니다. */
    template<int ..._rest>
    struct CharSetParserImpl<0, _rest...> : public CharSetParserImpl<>
    {};
 
/* 그 다음으로 시작부분의 문자열이 X-Y 형태인 경우입니다.
이 부분은 CharRange<X, Y>로 해석하면 됩니다.
이 경우는 항상 X < Y여야겠지요?
그래서 static_assert로 X < Y가 아닌 경우는 에러를 띄워줍니다.
그리고 이 부분을 해석하고 나머지 부분에 대해서는 CharSetParserImpl<_rest...>에게 떠넘깁니다.
현재 앞부분의 해석결과인 CharRange<...>를 뒷부분의 해석결과와 OrConcat로 연결하면
최종적인 파싱 결과가 됩니다.
*/
    template<int _c1, int _c2, int ..._rest>
    struct CharSetParserImpl<_c1, '-', _c2, _rest...>
    {
        static_assert(_c1 < _c2, "A range 'x-y' should satify x < y");
 
        using type = typename OrConcat<
            CharRange<_c1, _c2>,
            typename CharSetParserImpl<_rest...>::type
        >::type;
    };
 
/*
마지막으로 X-Y가 아닌 형태입니다.
이 경우는 각각의 문자를 나타내는 것이므로 OneChar로 해석하면 됩니다.
위와 마찬가지로 나머지 부분의 해석은 CharSetParserImpl<_rest...>에게 떠넘깁니다.
최종 파싱 결과는 앞부분의 해석결과인 OneChar<...>와 뒷부분의 해석결과를
OrConcat로 연결해준 것입니다.
*/
    template<int _chr, int ..._rest>
    struct CharSetParserImpl<_chr, _rest...>
    {
        using type = typename OrConcat<
            OneChar<_chr>,
            typename CharSetParserImpl<_rest...>::type
        >::type;
    };
 
}
/*
조금 복잡하긴 하지만
CharSetParserImpl<>::type 은 CharOr<>이 되고
CharSetParserImpl<'a', 'b'>::type은 CharOr<OneChar<'a'>, OneChar<'b'>>가 됩니다.
CharSetParserImpl<'a', '-', 'c', 'd'>::type은 CharOr<CharRange<'a', 'c'>, OneChar<'d'>>가 됩니다.
*/

쪼오오금 복잡하긴 하지만, 일단 X-Y처럼 중간에 -가 들어간 경우는 CharRange로, 그렇지 않은 경우는 OneChar로 해석하여 CharOr로 모두 이어주는 파서를 만들었습니다. 그리고 C++11에서 지원하지 않는 문자열 템플릿 인자를 우회하기 위해 다음과 같이 매크로를 사용하도록 합시다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#define PP_GET_1(str, i) \
(sizeof(str) > (i) ? str[(i)] : 0)
 
#define PP_GET_4(str, i) \
PP_GET_1(str, i+0),  \
PP_GET_1(str, i+1),  \
PP_GET_1(str, i+2),  \
PP_GET_1(str, i+3)
 
#define PP_GET_16(str, i) \
PP_GET_4(str, i+0),   \
PP_GET_4(str, i+4),   \
PP_GET_4(str, i+8),   \
PP_GET_4(str, i+12)
 
#define PP_GET_64(str, i) \
PP_GET_16(str, i+0),  \
PP_GET_16(str, i+16), \
PP_GET_16(str, i+32), \
PP_GET_16(str, i+48)
 
/*
위 매크로는 문자열 리터럴을 단일 문자들의 연속으로 분해해줍니다. 즉,
PP_GET_4("abc", 0)은 'a', 'b', 'c', 0 으로 해석되는 식이죠.
 
최종적으로 드디어
CharSetParserImpl<PP_GET_64("A-Fa-f0-4!?", 0)>::type is_in_something; 과 같은식으로 쓸 수 있습니다.
그러면 is_in_something('a')는 true, is_in_something('g')는 false를 돌려줄거구요.
*/

드디어 지저분한 식을 간단하게 쓸수 있게 되었습니다. CharOr<CharRange<'A', 'F'>, CharRange<'a', 'f'>> is_a_f; 대신 CharSetParserImpl<PP_GET_64("A-Fa-f", 0)>::type is_a_f; 라고 쓸 수 있어요~~!

마지막으로 기왕하는김에 정규표현식의 문자 집합마냥 negation 기능도 넣어봅시다. 정규표현식에서는 [A-Fa-f]는 A~F, a~f에 속하는 문자를 매칭하지만, ^가 앞에 붙은 [^A-Fa-f]의 경우 반대로 A~F, a~f에 속하지 않는 문자를 매칭하지요. 우리의 CharSet도 저걸 하도록 해봅시다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
/*
먼저 앞서 정의한 CharSet 템플릿 클래스를 조금 수정하겠습니다.
템플릿 인자로 제일 앞에 _negate를 추가하도록 합시다.
_negate가 false인 경우는 원래와 동일하게 비교를 수행하여 결과를 반환하고,
_negate가 true인 경우에는 참/거짓을 뒤집어서 반환하도록 합시다.
*/
template<bool _negate, typename _Ty>
class CharSet
{
public:
    bool operator()(int c) const
    {
        if(_negate) return !_Ty{}.test(c);
        return _Ty{}.test(c);
    }
};
 
template<bool _negate, typename _S1, typename _S2, typename _S3, typename ..._SRest>
class CharSet<_negate, CharOr<_S1, _S2, _S3, _SRest...>>
{
    using Trait = CharTrait<CharOr<_S1, _S2, _S3, _SRest...>>;
    std::array<bool, Trait::span> lut = {{ false, }};
public:
    CharSet()
    {
        detail::LUTSetter<_S1, _S2, _S3, _SRest...>{}(lut.data() - Trait::lowest);
    }
 
    bool operator()(int c) const
    {
        if (_negate)
        {
            if (c < Trait::lowest
                || c > Trait::highest) return true;
            return !lut[c - Trait::lowest];
        }
        if (c < Trait::lowest
            || c > Trait::highest) return false;
        return !!lut[c - Trait::lowest];
    }
};
 
/*
그리고 최종적으로 CharSetParser를 만들어봅시다.
기본적으로 detail의 CharSetParserImpl을 호출하고
그걸 CharSet에 감싸서 결과 타입을 생성합니다.
시작 문자가 '^'인 경우에는 _negate를 true로 해주고요.
*/
template<int ..._chrs>
struct CharSetParser
{
    using type = CharSet<false, typename detail::CharSetParserImpl<_chrs...>::type>;
};
 
template<int ..._chrs>
struct CharSetParser<'^', _chrs...>
{
    using type = CharSet<true, typename detail::CharSetParserImpl<_chrs...>::type>;
};

드디어 길고 긴 구현이 끝났습니다. 이제는 CharSetParser<PP_GET_64("A-Fa-f0-4!?", 0)>::type is_in_something;처럼 써서 제일 위에서 구현한 is_in_something 함수의 LUT버전을 손쉽게 만들 수 있습니다. negation이 필요한 경우는 CharSetParser<PP_GET_64("^A-Fa-f0-4!?", 0)>::type is_not_in_something; 처럼 쓰면 되겠죠.

한계

이 구현방법은 컴파일 타임에 입력된 패턴 문자열에 따라 타입을 생성하는 방식이므로, "A-Fa-f0-4!?"와 같은 문자열이 실시간으로 입력되어서 그걸 분석해서 판단해야하는 경우는 쓸수 없습니다. 또한 아직 C++11에서는 문자열을 템플릿 인자로 받아들이는 기능이 없어서 PP_GET_과 같이 여전히 지저분한 매크로를 써야한다는 문제도 있습니다. C++17이후로는 이 제한이 풀리므로, 해당 버전을 사용한다면 훨씬 이쁜 코드도 가능합니다. 

구현을 하다보니 좀만 더 노력하면 아예 정규표현식을 템플릿으로 구현할 수 있을거 같아 보이죠? 사실 C++의 템플릿 문법이 그 자체로 튜링 완전하기 때문에 어떤 작업도 템플릿을 통해서 구현할 수 있습니다. (다만 그 작업이 너무 고통스러울 뿐이지...) 실제로 C++17 및 그 이후 문법을 통해 정규표현식을 컴파일 타임에 분석하는 라이브러리도 있습니다. (https://github.com/hanickadot/compile-time-regular-expressions) 그러나 아직 C++11도 제대로 사용할 수 없는 레거시 환경들이 많기 때문에 손가락만 빨면서 구경할 뿐입니다.

다행히도 오늘 소개한 코드는 gcc 4.8에서부터는 컴파일 가능하므로 조금 지저분하고 기능은 미약하지만  범용성은 있다고 할 수 있겠습니다. 

관련글 더보기