본문으로 바로가기

C++20) Concepts ( 콘셉트, 개념 ) - 3

category C++/Modern 2020. 11. 28. 05:43

Concepts: Generic Programming의 미래

모든 내용은 Bjarne Stroustrup 교수님의 Good_Concepts에서 발췌하였습니다.

 

5. Concepts로 디자인 하기

 

좋은 Concept는 무엇인가?

이상적으로 Concept는 어떤 영역에서 근본적인 '개념'을 나타냅니다. 따라서 Concept는 의미론( semantics )을 가집니다.

이것은 무언가를 의미합니다. 그것은 단지 관련없는( unrelated ) 작업 및 유형 집합입니다.

operations이 무엇을 의미하고, 그들이 서로 어떻게 관련되어 있는지에 대한 아이디어가 없으면, 모든 적절한 유형에 대해 작동하는 

Generic Code를 쓸 수 없습니다.

 

불행하게도, 코드에서 concept의 의미를 ( 아직은 ) 말할 수 없습니다.

개념 점검 ( concept checking )에 의해 받아들여지는 모든 유형이 올바르게 작동한다는 보장은 불가능합니다.

그들은 정확히 필요한 구문적 속성 ( syntactic properties )을 가지고 있을 순 있지만, 잘못된 의미론을 가지고 있습니다.

이건 새로운 게 아닙니다. double을 사용하는 함수는 호출자가 기대하는 바와는 다르게 행동할 수 있습니다.

 

예를 들어, set_speed(4.5) 라는 함수가 있다고 합시다. 이게 대체 무엇을 뜻합니까?

4.5가 m/s 여야 합니까,  miles/hour 여야 합니까?, 4.5가 절대값입니까? 현재속도에 대한 delta 입니까? 아니면 변화량입니까?

 

모든 코드를 완벽하게 확인하는 것이 영원히 우리를 피할 것( elude )이라고 생각합니다.

 

5.1 Type/Concept의 우발적( accidental ) 일치

 

첫째로, 일반적인 디자인 실수를 해보겠습니다. 이것은 좋은 디자인을 쉽게 설명할 수 있게 해줄 것입니다.

최근에 오래된 OO 문제의 concept 버전을 보았습니다.

template <typename T>
concept Drawable = requires(T t) { t.draw(); };

class Shape {
    // ...
    void draw(); // 화면에서 선택한 픽셀을 밝힙니다.
};

class Cowboy {
    // ...
    void draw(); // 권총집에서 권총을 뺍(draw)니다.
};

template <Drawable D>
void draw_all(vector<D*>& v) // 모든 Shape를 그립니다.
{
    for(auto x : v) v->draw();
}

draw_all 함수는 OO counterpart와 마찬가지로, vector<Cowboy*>를 사용할 수 있을 것이고, 잠재적인 손상( damaging )효과가 일어날 것 입니다.

(overloading과 hierarchies 사이에서) 이러한 '사고( accidental )일치' 문제는 널리 우려되고 있으며, 실제 코드에서는 드물고, concepts에서는 쉽게 피할 수 있습니다.

 

'인수를 갖지 않는 draw 멤버 함수를 가지고있는' 이 나타내는 기본( fundamental ) 개념은 무엇입니까? 좋은 대답은 없을 것입니다.

Cowboy는 게임 컨텍스트( game context )에서, 좋은 개념( concept )을 만들 수 있고,  그릴 수 있는 Shape는 그래픽 컨텍스트 ( graphics context ) 에서 좋은 개념을 만들 수 있을 것입니다. 그리고 우린 그것들을 절대 헷갈리지 않아 할 것입니다.

 

Shape는 단순히 '그릴 수 있다', '위치', '움직일 수 있다', '숨겨져있다' 같은 본질적인 성질을 가지고 있고

Cowboy는 '말을 탈 수 있다', '술을 좋아한다', '죽을 수 있다' 를 가지고 있습니다.

즉 세심하게 명시된 필수 속성 전체를 요구하는 개념은 다른 개념으로 오인될 가능성이 없습니다.

 

가장 최우선으로 꼽는 것은, '단일 속성 Concepts를 피하라' 입니다. 그런 이유로, Drawable은 즉각 의심을 갖습니다.

 

여기, application 제작자들에게는 절대 노출돼서는 안되는 좋은 사례가 있습니다.

좀 더 현실적이기 위해서, 사람들은 때때로 이런 것을 정의하는데 어려움을 겪습니다.

template <typename T>
concept Addable = requires(T a, T b) { {a + b} -> T; };

사람들은 종종 std::stringAddable 하다는 것을 알게되면 놀라곤 합니다. ( std::string은 operator+를 제공하지만 더하기보단, 연결임 )

Addable은 Generic한 사용에 적합한 개념이 아니며, 기본적인 user-level 개념을 나타내지 않습니다.

Addable 한데, 왜 Subtractable 하지 않습니까? ( std::stringSubtractable 하지 않습니다. int* 는 가능합니다. )

이런 문제는 '단순하고, 단일 속성 Concepts'에 흔히 나타납니다. 대신 'Number' 같은 것을 정의하십시오.

template <typename T>
concept Number = requires(T a, T b) {
    {a + b} -> std::same_as<T>;
    {a - b} -> std::same_as<T>;
    {a * b} -> std::same_as<T>;
    {a / b} -> std::same_as<T>;
    {-a} -> std::same_as<T>;

    {a += b} -> std::same_as<T&>;
    {a -= b} -> std::same_as<T&>;
    {a *= b} -> std::same_as<T&>;
    {a /= b} -> std::same_as<T&>;

    { T{ 0 } };		// 0이라는 값을 통해 생성할 수 있다.
};

이것은 본의 아니게 일치될 가능성이 극히 낮습니다.

 

Domain 전문가가 예상할 수 있는 operations과 member types같은 속성을 제공해야 한다.

DrawableAddable 같은 실수는 디자인 원리와 무관하게 언어 특징을 순진하게 사용하는 것이었습니다.

 

Number는 예시에 불과하다는 점에 유의하세요.

만약 내가 C++ 산술( arithmetic ) 타입들을 설명해야 한다면, 나는 signed/unsgiend와, 혼합 산술( mixed-mode arithmetic )을 다뤄야함의 필요성이 있다고 할 것입니다.

만약 내가 컴퓨터 대수학( computer algebra )을 묘사하고 싶다면, monoid, semi-group 같은 집단에서부터 시작했을 것입니다.

 

5.2 의미론 ( semantics )

 

유용한 개념을 설계하기 위해 어떻게 유용한 특성 집합을 찾을 수 있을까요? 대부분의 응용 분야에는 이미 있습니다. 예를 봅시다.

  • C/C++ 기본 타입 (built-in type) 개념들 : 산술, 정수, 그리고 부동 소숫점 ( 맞아요, C는 개념을 가지고 있습니다! )
  • Iterator와 Container 같은 STL 개념들
  • monoid, group, ring, field 같은 수학 개념들
  • edges, vertices, graph, DAG 같은 그래프 개념들

그 중 하나는, 알고리즘과 타입을 '플러그 호환( plug compatible )' 으로 만드는데 도움이 됩니다.

template <typename Iter, typename Val>
Val sum(Iter first, Iter last, Val acc)
{
    while(first != last){
        acc += *first;
        ++first;
    }
    return acc;
}

 

template <Forward_Iterator Iter, typename Val>
requires Incrementable<Val, Value_type<Iter>>
Val sum(Iter first, Iter last, Val acc)
{
    while(first != last){
        acc += *first;
        ++first;
    }
    return acc;
}

Incrementableoperator+=를 요구하는 개념입니다.

분명히 sum을 디자인 하는 사람이 필요로 하는 작업을 최소화 합니다. 또한 인수 및 합계 알고리즘의 유용성을 최대화 합니다.

그러나

  • Val 이 복사 또는 이동이 가능해야 한다는 것을 잊었습니다.
  • operator+와 operator=를 제공하는 Val에는 이 합계를 사용할 수 없습니다.
  • 요구사항 (함수 인터페이스의 일부)을 변경하지 않고 operator+= 대신 operator+와 operator=를 사용하도록 바꿀 수 없습니다. 

이것은 plug compatible 이 아니고 ad-hoc 입니다. 이런 디자인은 다음과같은 프로그램으로 이어집니다.

  • 모든 알고리즘에는 고유한 요구사항이 있습니다. (우리가 쉽게 기억할 수 없는 다양성)
  • 모든 타입은 지정되지 않고 변경되는 일련의 요구 사항과 일치하도록 설계되어야 합니다.
  • 알고리즘의 구현을 개선할 때, 알고리즘의 요구사항(인터페이스의 일부)을 변경해야하며,
    concepts 코드를 다시 들여다봐야 합니다.

이것엔 어려움이 좀 있습니다. 따라서 이상적인 것은 '최소 요구 사항( minimal requirements )' 이아니라 '기본적이고 완전한 개념으로 표현되는 요구사항( requirements expressed in terms of fundamental and complete concepts )'입니다.

이는 타입 디자이너들에게 부담을 주지만 ( concepts와 일치 ) 더 나은 타입과 더 유연한 코드로 이어집니다.

 

예를 들어, sum 함수를 더 개선한다면, 

template <Forward_iterator Iter, Number<Value_type<Iter>> Val>
Val sum(Iter first, Iter last, Val acc)
{
    while(first != last){
        acc += *first;
        ++first;
    }
    return acc;
}

Number를 요구사항으로 추가하면서, 유연성을 확보했다는 점에 유의하세요.

또한 sum을 사용하여 std::string을 합치는 경우나 이나 std::vector<int> 를 char*로 합하는 경우를 제외시켰습니다.

void poor_use(vector<string>& vs, vector<int>& vi)
{
        std::string s;
	s = sum(vs.begin(),vs.end(),s); // Ill-formed, std::string은 Number가 아닙니다.
	char* p = nullptr;
	p = sum(vi.begin(),vi.end(),p); // Ill-formed, pointer는 Number가 아닙니다.
	// …
}

이처럼 의도적으로 쉽게 작성하세요.

 

좋은 개념을 설계하고 개념을 잘 사용하려면 구현이 명세( specification )가 아니라는 것을 기억해야 합니다.

언젠가 누군가는 구현한 것을 개선하고 싶어할 것이고, 인터페이스에 영향을 주지않고 이상적으로 수정하는 것을 원할 것입니다.

사용자 코드가 깨질수도 있기 때문에, 인터페이스를 변경하는 경우가 자주 발생합니다.

유지관리 가능하고 광범위하게 사용할 수 있는 코드를 쓰기위해 각 개념과 알고리즘을 분리하여 최소화하는 것보다는 의미적 일관성( semantic coherence )을 목표로 합니다.

 

5.4 제약 ( Constraints )

 

여기서 설명된 개념의 관점은 다소 이상적( idealistic )이며 응용 프로그램 영역에서 응용 프로그램 작성자가 사용할 '최종( final )' 개념을 생성하는 것을 목표로 합니다.

개념(Concepts)는 특히 새로운 애플리케이션의 개발 초기단계에서 매우 유용할 수 있습니다.

예를 들어, 위의 Number 개념은 복사 가능하거나 이동 가능해야 하는 요구를 '잊었기' ( forgot ) 때문에 불완전 합니다.

라이브러리는 그러한 불완전성을 피하기 위해 우선권( precedence )과 지원( supporting ) 개념 ( Concepts )을 제공합니다.

 

그렇더라도, Number를 사용하면 많은 오류를 제거할 수 있습니다. 누락된 산술 연산과 관련된 모든 오류를 포착( catches )합니다.

그러나 사용자가 산술 연산을 제공하지만 복사하거나 이동할 수 없는 타입으로 sum을 호출하는 경우, Number를 사용하는 sum은 오류를 포착하지 못합니다. ( Copyable 또는 Moveable의 요구를 하지 않았습니다. )

 

하지만 괜찮습니다. 단지 우리가 수십 년 동안 익숙해져 온 오류 메세지 중 하나를 얻을 뿐입니다.

시스템은 여전히 타입 안전( type safe ) 합니다. 나는 '불완전한 개념'을 개발 및 점진적인 개념 도입에 대한 중요한 보조 도구로 생각합니다.

일반적 용도에 너무 단순하거나 명확한 의미론적 요소가 결여된 개념은 보다 완전한 개념을 위한 구성 요소로 사용될 수 있기 때문입니다.

 

때떄로 그러한 지나치게 단순하거나 불완전한 개념을 '실제 개념( real concepts )'과 구별하기 위해 '불완전한 개념( incomplete concepts )' 이라고 부릅니다.

 

5.5 개념의 타입 일치

 

새로운 유형의 타입이 개념과 일치하는지 어떻게 확신할 수 있을까요? 

단순하게 : 원하는 개념이 일치한다고 가정할때 static_assert를 사용하면 됩니다. 예를 들어,

class My_number { /* … */ };
static_assert(Number<My_number>);
static_assert(Group<My_number>);
static_assert(Someone_elses_number<My_number>);

class My_container { /* … */ };
static_assert(Random_access_iterator<My_container::iterator>);

결국 Concepts는 단순히 술어이므로 검사할  수 있습니다. 그것들은 컴파일 타임 술어들이기 때문에 컴파일 타임에 검사할 수 있습니다.

타입 정의에 일치시킬 일련의 개념을 구축할 필요가 없다는 점에 유의하십시오. ( 개념 집합을 구축할 필요는 없습니다. )

이것은 새로운 용도가 발견될 때마다 완벽한 예측이 필요하거나 리팩토링이 필요한 일종의 계층 설계가 아닙니다.

이는 객체 지향 계층 구조와 비교하여 Generic Code의 이점을 얻는데 매우 중요합니다.

static_asserts는 타입 디자이너의 코드 안에 있을 필요도 없습니다.

사용자는 코드 내의 에러를 검사하기 위해 이러한 테스트를 추가할 수 있습니다.

만약 그렇다면, 라이브러리 코드를 수정하지 않고 이렇게 하는 것이 필수적입니다.

 

5.6 개념의 점진적 도입

 

어떻게 개념을 사용하기 시작할 수 있을까? 

우리는 다른 사람의 코드( 예를 들어, 라이브러리 )에 의존하고, 기존 코드 베이스를 업데이트 하고 있다면 다른 사람의 코드는 우리의 코드에 의존합니다.

특히, 일반적으로 라이브러리( 예를 들어, 표준 라이브러리 또는 인기 있는 네트워크 라이브러리 )에 의존하고 있으며, 수년 동안 그러한 라이브러리는 concepts를 사용하지 않을 수 있습니다.

그러므로 concepts를 사용하지 않는 우리의 코드 호출 템플릿을 발견할 것입니다.

template<Sortable S>
void sort(S& s)
{
    std::sort(s.begin(),s.end());
}

우리의 sort는 s가 Sortable임을 요구해야하는데, std::sort는 요구합니까? 

이 특별한 경우, 표준을 살펴볼 수 있지만, 일반적으로 구현에 대한 세부사항은 정확하게 명시되어 있지 않습니다.

또한 구현에는 'scaffolding code'가 포함될 수 있습니다.

즉, 호출 시점에서 구현이 요구사항을 충족하는지 알 수 없습니다. ( 컴파일 에러를 배출하지 않습니다. )

라이브러리 업체가 템플릿 인터페이스를 업그레이드 함에따라, 오류 검사가 가능해집니다.

호출 지점에 오류가 나타날 것이고, 오류 메세지의 품질이 향상될 것 입니다.

 

컴파일러를 사용하여 컴파일 할 수 있어야 하는 경우, Concepts를 지원하는지 안하는지에 대한 몇 가지 해결 방법이 필요합니다.

한 가지 분명한 기술은 매크로를 사용하는 것입니다.

#ifdef GOOD_COMPILER
#define REQUIRES requires
#elseif
#define REQUIRES //
#endif

속기 표기를 사용하는 경우 Concepts를 사용해야 합니다.

#ifdef GOOD_COMPILER
#define SORTABLE Sortable
#define ITERATOR Iterator
#elseif
#define Sortable auto
#define ITERATOR auto
#endif

concepts 기반 overloading을 위해 enable_if 같은 것을 사용할 수 있습니다.

이것은 효과가 있지만, 실제 코드를 유지하는 것이 상당히 고통스럽다고 합니다. ( 예를 들어, Ranges 라이브러리 )

특히 양수와, 음수를 모두 체크하는 것을 잊지마세요.