아키텍처 패턴 열 편이 시스템 전체의 배치였다면, 이쪽은 클래스 몇 개 사이의 관계다.
그리고 C++에서는 이 크기의 패턴 상당수가 std::function + 람다 한 줄로 접힌다.
한 줄 요약
멤버 함수가 하나뿐인 추상 인터페이스는
std::function으로 바꿀 수 있다.
GoF 패턴 중 여럿이 정확히 그 모양이다 — 인터페이스 하나, 순수 가상 함수 하나, 그것을 구현한 클래스 여럿. 그 구조를 클래스 대신 값으로 다루면 클래스가 통째로 사라진다.
graph LR subgraph 클래스판 I["«interface»<br/>Command<br/>+ action()"] C1[AwakeCommand] --> I C2[SleepCommand] --> I C3[PlayCommand] --> I end subgraph 람다판 F["std::function<void()>"] L1["[]{ 일어난다 }"] --> F L2["[]{ 잔다 }"] --> F L3["[]{ 논다 }"] --> F end
왼쪽은 동작을 하나 늘릴 때마다 클래스가 하나 는다. 오른쪽은 안 는다.
접히는 조건
전부 접히지는 않는다. 아래를 다 만족할 때만 깔끔하게 접힌다.
- 인터페이스의 메서드가 하나다. 둘 이상이면
std::function을 여러 개 들고 다녀야 하는데, 그 순간 클래스가 더 낫다. - 구현체가 상태를 거의 안 가진다. 상태가 필요해도 캡처로 충분한 정도여야 한다.
- 구현체에 이름이 필요 없다. 타입 이름으로 구분하거나
dynamic_cast로 갈라 볼 일이 없어야 한다. - 수명이 단순하다. 참조 캡처가 대상보다 오래 살면 그대로 사고다.
접히지 않는 경우
블랙보드 패턴이 실물 반례다. 거기 KnowledgeSource는 메서드가 셋이다 —
name(), canContribute(), contribute(). 하나로 안 접히니 추상 클래스로 뒀다.
그 외에 접지 말아야 할 신호들.
- 구현체마다 여러 동작이 한 묶음으로 움직인다(1번 위반).
- 구현체가 무거운 상태를 들고 있다. 캡처로 옮기면 복사 비용이나 수명 문제가 생긴다.
- 런타임에 타입을 구분해야 한다. 람다는 타입이 각자 달라서 이름으로 잡을 수 없다.
std::function의 간접 호출 비용이 문제 되는 뜨거운 경로. 이때는 템플릿 매개변수로 받아 인라인되게 하는 쪽이 낫다(아래).
무너지는 지점을 코드로
말로만 하면 와닿지 않으니, 같은 기능에 요구를 하나씩 더해 가며 접기가 어디서 무너지는지 본다. 아래 코드는 한 프로그램에서 뽑은 것이고, 실제로 컴파일해 돌린 결과를 같이 싣는다.
문서 편집기의 “명령”을 예로 든다. 세 단계에서 인터페이스가 이렇게 달라진다.
// 1단계 — 메서드 하나. 접힌다.
using Command = std::function<void(std::string&)>;
// 2단계 — undo가 필요해졌다. 메서드가 둘이니 접기 조건 위반이다.
// 클래스 계층 없이 버티는 방법은 값 타입 하나에 둘을 묶는 것.
struct Command {
std::function<void(std::string&)> redo;
std::function<void(std::string&)> undo;
};
// 3단계 — 명령에 이름이 필요해졌다(로그·UI·종류별 취소).
// 람다는 이름도 타입도 없어 구분할 수 없다. 클래스가 낫다.
struct Command {
virtual ~Command() = default;
virtual const char* name() const = 0;
virtual void redo(std::string&) = 0;
virtual void undo(std::string&) = 0;
};2단계의 undo가 캡처로 해결되는 것이 재미있다. append(" world")가 돌려주는 쌍에서
undo는 지운 글자 수만 캡처하면 된다.
Command append(std::string text) {
return {
[text](std::string& d) { d += text; },
[n = text.size()](std::string& d) { d.erase(d.size() - n); },
};
}실행하면 세 단계가 차례로 나온다.
$ ./fold-limit
메서드 1개 — 접힌다
1단계 결과: hello world!
메서드 2개 — 값 타입에 묶는다
2단계 실행: hello world!
2단계 취소: hello world
메서드 3개 + 신원 — 클래스가 낫다
3단계 실행: []
3단계 취소 대상: Clear
3단계 취소 대상: Append
3단계 복원: [hello]3단계 출력의 “취소 대상: Clear” 가 요점이다. 이름으로 명령을 골라내는 이 한 줄이 1·2단계에서는 불가능하다. 람다에는 물어볼 이름이 없기 때문이다.
즉 접기는 요구가 늘면 단계적으로 무너진다. 처음부터 클래스를 만들 이유는 없지만,
undo나 이름이 필요해지는 순간이 갈아탈 지점이다.
std::function이 유일한 방법은 아니다
동작을 값으로 받는 방법이 둘이다.
// (1) 템플릿 — 타입이 그대로 보존되고 인라인될 여지가 있다
template <typename F>
void forEach(const std::vector<int>& v, F f) { for (int n : v) f(n); }
// (2) std::function — 타입이 지워진다. 헤더에 두지 않아도 되고 컨테이너에 담을 수 있다
void forEach(const std::vector<int>& v, std::function<void(int)> f) {
for (int n : v) f(n);
}- 컨테이너에 담아야 하거나, 나중에 갈아 끼워야 하면
std::function이다(타입이 하나로 통일된다). - 호출 지점에서 바로 쓰고 버릴 거라면 템플릿이 싸다. 간접 호출도 힙 할당도 없다.
문서
| 문서 | 패턴 | 축 |
|---|---|---|
| gof-command.md | Command | 클래스 폭발이 0으로 — 이 갈래의 대표 사례 |
| gof-strategy.md | Strategy | 가장 순수한 “메서드 하나” 인터페이스 |
| gof-iterator.md | Iterator | 접는 순간 잃는 것이 생기는 사례 |
| gof-abstract-factory.md | Abstract Factory | 절반만 접힌다 |
참고
- 최흥배, 「클로져 디자인 패턴 — C++11 람다식에 의한 디자인 패턴」(2018) —
이 갈래를 시작하게 된 자료다. Moriharu Ohzu의 CEDEC 2015 발표를 옮기고 해설을 더한 것이다.
“멤버 함수 하나짜리 추상 인터페이스는
std::function으로 바꿀 수 있다” 는 결론을 여기서 얻었고, 그 축으로 다시 세운 것이 이 문서 묶음이다. 내용은 옮기지 않았다.- 자료가 쓰는 Pluggable Behavior · Execute Around Method · Loan 같은 이름은 GoF 표준 용어가 아니라 그 발표의 분류다. 인용할 때 출처를 밝혀야 한다.