SmalltalkBestPracticePatterns:5.3
- 5.3 컬렉션 프로토콜(Collection Protocol)
컬렉션 프로토콜(Collection Protocol)
컬렉션 클래스들에 대한 프로토콜이 획일화되었다는 점은 컬렉션 프로토콜의 가장 큰 강점들 중 하나이다. 고객의 코드는 공통 메시지 집합을 사용하여 어떻게 컬렉션을 저장하는지에 관한 결정으로부터 효과적으로 분리된다.
많은 스몰토크 프로그래머들은 모든 컬렉션 프로토콜을 학습하는 데에 오랜 시간을 투자한다. do:, add:, size와 같은 소수의 메시지부터 시작해 오랜 시간 그 메시지들을 붙들고 시간을 보낸다.
필자도 처음 스몰토크에서 프로그래밍을 시작했을 때 어쩔 줄 몰라했던 기억이 난다. 기본적인 기능을 수행하는 데만 해도 얼마나 많은 것을 학습해야 했던지. 하지만 컬렉션 프로토콜은 시스템에서 가장 영향력이 큰 부분들 중 하나다. 이것을 잘 활용할수록 더 빠르게 코딩하고 타인의 코드를 더 빠르게 읽을 수 있다.
해당 절에서는 컬렉션 레퍼토리(repertoire)에서 가장 중요한 메시지들을 강조하고자 한다. 물론 완전한 목록은 아니다. 패턴에 대한 메시지들 중 가장 자주 누락되거나 잘못 사용되는 메시지들만 선정하였다.
이러한 메시지의 사용을 숙달하고 나면 하루 정도 시간을 내어 어떤 컬렉션 클래스에서 어떤 메시지를 이용할 수 있는지 철저히 연구해보라. 머지않아 전문가처럼 컬렉션을 코딩할 수 있을 것이다.
IsEmpty
- 컬렉션이 비어있는지는 어떻게 시험하는가?
이와 관련된 문제를 언급할 것이라고 생각을 못했지만 반복적으로 아래와 같은 코드를 목격했기 때문에 소개하고자 한다:
...aCollection size = 0 ifTrue: ...
또는
...aCollection size > 0 ifTrue: ...
크기가 0 또는 그 이상의 값인지 확인하는 것은 더 높은 수준의 개념 구현, 즉 "해당 컬렉션이 비어있는가?"를 의미한다.
컬렉션 프로토콜은 컬렉션이 비어있는지 검사하는 단순한 메시지들을 제공한다. 이를 사용 시 가독성이 조금 더 뛰어난 코드가 된다.
- 컬렉션이 비어있는지 시험하려면 (요소가 없는지) isEmpty를 전송하라. 컬렉션에 요소가 있는지를 시험하려면 notEmpty를 사용하라.
- 모든 스몰토크는 사용자가 답을 입력(type)하도록 요청하는 대화창을 띄울 수 있는 간단한 기능을 가진다. 답란이 비어 있다면 동작을 진행하지 말아야 함을 의미한다. 아래는 일반적인 코드이다 (VisualWorks 스타일):
| answer |
answer := Dialog request: 'Number, please?'.
answer isEmpty ifTrue: [^self].
...
VisualSmalltalk와 VisualAge는 notEmpty를 정의하지만 VisualWorks 2는 그렇지 않으므로 자신이 추가해야 한다는 사실을 주목한다. isEmpty의 값은:
aCollection size = 0
위의 코드와 아래의 코드 사이에:
aCollection isEmpty
물론 어느 정도 차이는 있지만 그렇게 큰 차이가 있다는 의미는 아니다. 중요한 점은 관용구버전을 즉시 읽을 수 있다는 사실이다. 크기를 명시적으로 확인하는 코드를 보면 잠시 동작을 멈추고, 확실하진 않지만 특별한 일이 일어나고 있는지 확인한다.
모든 문화는 고유의 어휘를 발전시킨다. 자동차 수리점에서 "너비를 조정하는 들숙날쑥한 휠이 달린 렌치 좀 건네줘,"라는 말을 들으면 별로 차를 맡기고 싶지 않을 것이다. 정비공이라면 몽키 스패너 정도는 알아야 할테니 말이다.
Includes:
- 컬렉션에서 어떻게 특정 요소를 검색하는가?
많은 사람들은 검색을 구현 시 열거 프로토콜을 사용해야 한다고 가장 먼저 답할 것이다. 그들은 다음과 같은 코드를 작성할 것이다:
| found |
found := false.
aCollection do: [:each | each = anObject ifTrue: [found :=
true]].
...
좀 더 경험이 많다면 열거 메시지도 좀 더 복잡해진다:
| found |
found := (aCollection
detect: [:each | each = anObject]
ifNone: [nil]) notNil.
...
컬렉션 프로토콜은 정확히 이러한 일을 수행하는 메시지, 즉 includes:를 제공한다.
위의 코드는 includes:를 이용해 아래와 같이 변경될 것이다:
| found |
found := aCollection includes: anObject
...
심지어 임시 변수를 제거하고 행에 표현식을 바로 사용할 수도 있다.
중요한 건 includes:를 최적화에 이용할 수 있다는 점이다. Set와 같은 일부 컬렉션 종류는 컬렉션의 크기와 무관하게 일정한 시간으로 includes:를 실행한다. 위에서 includes:에 대한 대안방법도 있지만 컬렉션의 크기에 비례해 항상 소요 시간이 증가할 것이다.
- includes:를 전송하고, 검색할 객체를 전달하라.
수신자가 사람을 고용할 경우 Collection Accessor Method의 예제는 true를 리턴한다:
employs: aPerson
^employees includes aPerson
includes는 두 개의 컬렉션의 교집합(intersection)을 계산하는 데에 일반적으로 사용된다:
collection1 select: [:each | collection2 includes: each]
정체성 대신 내용을 기반으로 자신만의 객체를 검색할 경우 객체에 대해 Equality Method(p.124)와 Hashing Method(p.126)을 구현해야 할 것이다.
Concatentation
- 두 개의 컬렉션을 어떻게 합치는가?
Concatentation은 대부분 언어에서 중간 관용구(intermediate idiom)들 중 하나이다. 이것 없이도 살 수는 있지만 언젠간 학습해야 한다. 일반적인 concatentation 관용구는 아래와 같이 작동한다:
- 결과에 충분할 만큼 큰 컬렉션을 생성한다.
- 첫 번째 컬렉션을 결과의 첫 번째 부분에 복사한다.
- 두 번째 컬렉션을 결과의 두 번째 부분에 복사한다.
때때로 2, 3번에 해당하는 복사를 대신해주는 라이브러리 함수가 있다.
스몰토크는 이것을 첫 번째 컬렉션으로 보내는 단일 메시지, 즉 "," (콤마)로 간소화하여 두 번째 컬렉션을 argument로서 취한다.
많은 프로그래밍 언어들은 문자열을 특별하게 취급한다. 종종 "+"(더하기) 연산자(operator)가 문자열 결합(string concatenation)에 사용된다. 스몰토크에서 Strings는 문자의 집합체이다. 모든 일반적인 컬렉션 프로토콜이 작동한다. 문자열을 결합하기 위해서는, Arrays 또는 OrderedCollections를 다룰 때와 마찬가지로 ","를 사용한다.
두 번째 컬렉션을 argument로서 취하고 첫 번째 컬렉션으로 ","를 전송함으로써 두 컬렉션을 결합하라.
Concatentation은 String로부터 메시지와 일부 arguments를 구성할 때 주로 사용된다:
self error: anInteger printString , ' is too many objects for ' ,
aString
다수의 컬렉션을 합칠 경우 Concatenating Stream(p.165)를 사용할 필요가 있을 것이다.
열거(Enumeration)
어떻게 컬렉션에 걸쳐 코드를 실행하는가?
컬렉션이 있다면 당신은 해당 컬렉션으로 어떠한 행동을 취해야 한다. 컬렉션에 걸친 계산의 예제를 들자면 다음과 같다:
- Account는 그것의 모든 Transactions 값을 합산함으로써 잔고를 계산한다.
- Composite Visual은 그것의 모든 구성요소(component)를 표시함으로써 정보를 보여준다.
- Directory를 삭제 시 그것의 Files이 모두 제거된다.
절차적 프로그래머들은 이와 같은 코드를 작성하기 위한 관용구를 모은 도구상자를 개발한다. C 프로그래머에게 배열을 반복해주길 요청하면 대부분은 그들이 입력하는 속도만큼 빠르게 프로그래밍을 할 수 있을 것이다. 그러한 코드의 시각적 인식 또한 자동이 된다.
스몰토크에서 반복의 세부내용은 모든 컬렉션이 이해하는 획일화된 메시지 집합 뒤에 숨긴다. 스몰토크에서는 하나, 둘 또는 세 개의 행으로 된 관용구들보단 하나의 단어로 된 메시지를 사용한다. 이러한 메시지를 이용해 작성되는 코드는 올바르게 쓰기도, 읽기도 쉽다.
열거 메시지에는 여러 변형(variation) 형태가 있기 때문에 일부 스몰토크 프로그래머들은 하나 또는 두 개만 학습하고 나머지는 수동으로 코딩한다. 결국 그 곳에 있는 메시지를 사용할 때보다 크기는 더 크고 오류가 발생하기 쉬우며 읽기 힘든 코드를 작성하는 데에 더 많은 시간을 낭비한다.
- 컬렉션에 걸쳐 계산을 퍼뜨리기 위해서는 열거 메시지를 사용하라.
열거 메시지들은 빈 컬렉션에도 정상적으로 작동한다. 컬렉션이 빈 경우 이를 이용해 특수 case 코드를 피할 수 있다.
printChildren
children isEmpty ifTrue: [^self].
self children do: [:each | each print]
위의 코드는 아래와 정확히 동일하다:
printChildren
self children do: [:each | each print]
Collection을 반복하는 동시에 요소를 추가하거나 삭제하는 경우, 문제가 발생할 것이다:
aSet do: [:each | aSet remove: each]
이러한 유형의 코드를 쓰기 위한 호출이 없는 경우가 종종 있다. 불가피하게 호출해야 하는 경우 컬렉션의 복사본이 열거(enumerate)하도록 만들어라:
aSet copy do: [:each | aSet remove each]
단순한 열거를 위해선 Do(p.146)를 사용하라. 컬렉션의 요소를 변형하기 위해선 Collect(p.147)를 사용하라. 컬렉션의 특정 부분만 선택하려면 Select/Reject(p.149)를 사용하라. Detect(p.151)를 이용해 요소를 검색하라. 컬렉션에 걸쳐 누계(running total)를 유지하려면 Inject:Into: (p.152)를 사용하라.
Do
- 컬렉션 내 각 요소에 대한 코드를 어떻게 실행하는가?
이것은 기본적 메시지로서, 나머지 열거 메시지들이 이로부터 빌드된다. 만일 절차적 언어라면 컬렉션을 통해 반복되는 관용구의 작은 집합을 가지고, 연결 리스트용으로 하나, 배열용 하나, 해시 테이블용으로 하나가 있을 것이다.
열거를 목적으로 하는 경우, 스몰토크에선 컬렉션 클래스 간 차이가 없다. 프로그래머로서 당신은 루프 카운터의 반복 또는 리스트를 따르는 walking pointer를 절대 명시적으로 다루지 않는다. 단지 "do:"만 전송하면 마술이 펼쳐진다.
do:의 단순성에도 불구하고 필자는 종종 누군가 이전에 가진 습관에 따라 작성한 코드를 목격하곤 한다:
index := 1.
[index <= aCollection size] whileTrue:
[...aCollection at: index...
index := index + 1]
변명의 여지가 없다. do:를 이용한 코드가 훨씬 더 짧고 읽기 쉽다:
aCollection do: [:each | ...each...]
두 코드에는 작은 성능적 차이가 존재한다. Windows 3.0.1용 VisualSmalltalk에서 개방 코딩(open code)된 반복(iteration)을 1,000 element Array에 1 밀리 초로 측정한 반면 do:를 전송 시 1.7 밀리 초가 소요되었다. 어떠한 처리든 요소에서 실행 중이라면 루프 오버헤드(loop overhead)는 즉시 사라질 것이며, 추후에 다시 들어가 소수의 반복을 개방 코딩하더라도 별 문제가 되지 않는다.
- 컬렉션의 요소를 반복하도록 컬렉션에 do:를 전송하라. one argument 블록을 argument로서 do: 로 전송하라. 각 요소마다 한 번씩 평가될 것이다.
파라미터의 Collection에 대해서도 정의되는 Collection>>add: 와 같은 연산은 (Collection>>addAll:) 종종 do:를 비롯해 그보다 더 간단한 연산으로 구현된다:
Collection>>addAll: aCollection
aCollection do: [:each | self add: each]
필자는 Pascal과 C를 사용하며 자랐다. 그 중에서도 루프를 작성하고, 인덱스 증가(index increment)를 올바른 장소에 넣는 데에 소질이 있었다. 하지만 스몰토크를 사용한지 6개월 정도가 지났을 때 손으로 열거를 작성해야 하는데 루프 인덱스를 수동으로 업데이트하는 법을 잊은 적이 있다. 항상 증가를 넣는 것을 잊어버리거나 증가 대신 감소(decrement)를 해버리기 일쑤였다. 그럴 때마다 당혹스러움을 감출 수 없었다. 그 이후 그런 일은 거의 없어서 기쁘지만, 훌륭한 기술인데 비해 잊어버리기 쉽다.
블록에 Simple Enumeration Parameter(p.182)를 사용하라.
Collect
- 컬렉션 내 각 객체로 전송되는 메시지의 결과를 어떻게 작동시키는가?
이것이 오래된 절차적 프로그래밍이었다면 우리는 아마 열거 블록이 요소로 메시지를 보내고낸 그 이후 계산에 메시지의 결과를 사용하는 코드를 작성할 것이다.
self children do: [:each | self passJudgement: each
hairStyle]
헤어스타일을 처리하길 원하는 또 다른 코드도 위와 유사한 코드를 가질 것이다:
self children do: [:each | self trim: each hairStyle]
이는 다시 '꼭 한 번만' 규칙에 위배된다. 두 개의 코드 조각(fragment)은 자녀의 헤어스타일에 실행되는 연산을 제외하면 매우 유사해 보인다.
우리는 본래 컬렉션의 각 요소로 전송되는 메시지의 결과를 포함하는 중간 컬렉션을 생성함으로써 코드의 공통성(commonality)을 포착할 수 있다. 그리고 우리는 새 컬렉션에 열거(enumerate over)할 수 있다.
코드의 새로운 명확성을 위해서는 변형된 요소를 보유하는 새 컬렉션을 생성하고 컬렉션에 두 번 반복(iterate over)하는 수고를 해야 한다: 한 번은 그것을 변형하고 한 번은 그것으로 계산. 중간 컬렉션으로부터 발생하는 성능 문제를 발견하는 경우, 이 문제는 후에 쉽게 수정된다. 정보 전달은 확실히 그만큼 비용을 들일 가치가 있으며, 이를 수정하고 나면 성능 문제는 절대 발생하지 않을 가능성이 크다.
- 본래 컬렉션의 각 요소를 이용해 collect: 로 전달된 블록을 평가한 결과를 요소로 하는 새 컬렉션을 collect:를 이용해 생성하라.
위 코드의 공통 부분을 포착하기 위해 collect:와 함께 Composed Method를 이용할 수 있다:
childrenHairStyles
^self children collect: [:each | each hairStyle]
그리고 코드 조각을 간소화할 수 있다:
self childrenHairStyles do: [:each | self passJudgement: each]
self childrenHairStyles do: [:each | self trim: each]
어쩔 수 없다면 특수 Enumeration Method(p.144)와 함께 collect:를 이용해 코드의 성능을 개선하라. 블록 argument에서 Simple Enumeration Parameter(p.182)를 사용하라.
Select/Reject
- 컬렉션의 일부를 필터링하는 방법은?
절차적 해결방법은 두 가지 작업을 하는 열거 블록을 가지는 것이다 - 하나는 요소의 "흥미로움"을 검사하고, 하나는 흥미로운 요소들에 대해 일부 액션을 조건적으로 실행하는 일이다. 이러한 스타일로 작성된 코드는 아래와 같은 모습을 한다:
self children do: [:each | each isResponsible ifTrue: [self
buyCar: each]]
이와 같은 코드는 처음 작성할 때는 괜찮지만 동일한 필터를 다른 곳에 위치시키길 원할 가능성이 크다:
self children do: [:each | each isResponsible ifTrue: [self
addToWill: each]]
꼭 한 번씩만 말해야 한다는 규칙을 기억하는가? 위의 코드 두 조각은 이 규칙에 위배된다. 두 번째 꺾쇠괄호까지는 모든 것이 정확히 동일하다. 그러한 공통성을 어떻게서든 포착하고자 한다.
이에 대한 해결책으로는, 흥미로운 요소들만 포함하는 컬렉션을 생성하여 그 곳에서 작동시키는 방법이다. 새 컬렉션에는 비용이 든다ㅡ원본 컬렉션과 따로 생성되어야 한다. 좀 더 표현적인 코드를 만들지 못한다면 garbage 컬렉션이 얼마나 효율적인들 무슨 의미가 있겠는가. 중간 컬렉션을 생성하는 비용이 너무 비싸다면 후에 쉽게 수정할 수 있다.
- 흥미로운 요소들만 포함하는 새 컬렉션을 리턴하기 위해선 select:와 reject:를 사용하라. 새 컬렉션을 열거하라. 둘 다 부울값(Boolean)을 리턴하는 one argument Block을 취하라. Select:는 Block이 true를 리턴하는 요소들을 제공하고, reject:는 Block이 false를 리턴하는 요소들을 제공한다.
위의 두 코드 조각의 공통성을 확인하려면 select:와 함께 Composed Method를 이용해 책임이 있는 자녀(responsible children)를 리턴하는 메서드를 생성하라:
responsibleChildren
^self children select: [:each | each isResponsible]
그리고 두 개의 코드 조각을 간소화할 수 있다:
self responsibleChildren do: [:each | self buyCar: each]
self responsibleChildren do: [:each | self addToWill: each]
생성 비용을 피하기 위해 특수 목적용 Enumeration Method(열거 메소드)를 이용할 수 있다. 블록 argument에 Simple Enumeration Parameter를 이용하라. 성능을 최적화하려면 Lookup Cache를 사용하라.
Detect
- 컬렉션을 어떻게 검색하는가?
또 다른 공통된 컬렉션 관용구로, 특정 기준에 부합하는 요소의 검색을 들 수 있다. 물론 이것은 do:를 이용하고, 열거 블록 내에서 기준을 검사하며, 조건적으로 일부 코드를 실행하여 구현할 수 있다. 아래 코드는 책임이 있는 첫 자녀에게 자동차 키를 제공한다:
self children do: [:each | each isResponsible ifTrue: [each
giveKeys: self carKeys. ^self]]
조건부 코드가 한 번만 실행되어야 한다는 점을 주목하라. 이를 복합 루프에서 관리하면 따르기가 매우 힘든 코드가 발생할 수 있다.
- 컬렉션에 detect:를 전송함으로써 컬렉션을 검색하라. 블록 argument가 true로 평가하는 첫 번째 요소가 리턴될 것이다.
검색은 one argument 블록을 argument로 취하는데 이는 true 또는 false를 평가한다. 이는 블록이 true라고 평가하는 첫 번째 요소를 리턴한다. 위의 코드는 아래와 같이 된다:
(self children detect: [:each | each isResponsible])
giveKeys: self carKeys
detect:의 변형형태로, detect:ifNone:이 있는데, 이는 추가 제로 파라미터 Block을 argument로 취한다. 어떤 요소가 발견될지 확실하지 않을 경우 이를 이용하라.
Detect:ifNone:은 기발한 ("적어도 처음엔 읽기 힘들다") 관용구를 발생시킨다. 어떤 요소든 기준에 충족할 경우 메서드로부터 true를 리턴하고 나머지는 false를 리턴하길 원한다면 어떻게 할 것인가?
hasResponsibleChild
self children
detect: [:each | each isResponsible]
ifNone: [^false].
^true
이를 종종 이용하긴 하지만 그다지 자랑할만한 일은 아니다.
첫 번째 블록 argument에 Simple Enumeration Parameter(p.182)를 사용하라. 성능을 최적화하기 위해 Lookup Cache(p.161)를 사용하라.
Inject:into:
실행 중인 값을 유지하는 Enumeration(p.144)가 필요하다.
- Collection을 반복하는 동안 어떻게 실행 중인 값을 유지하는가?
모두가 학습하는 첫 번째 절차적 프로그래밍 패턴들 중 하나는 누계(running total)를 유지하는 방법이다.
- 합계를 초기화하라.
- 컬렉션의 각 요소마다 합계를 수정하라 (총계, 최소값, 최대값 등).
- 결과를 이용하라.
따라서 아래와 같은 스몰토크 코드를 많이 목격할 것이다:
| max |
max := 0.
self children do: [:each | max := max max: each value].
^max
사실 이것은 매우 흔한 코드라 이 작업을 대신 해주는 메시지도 있다.
그렇다면 이렇게 신비하고 강력한 메시지를 모든 사람이 사용하지 않는 이유는 뭘까? 그 이유는 바로 이름이 파격적이기 때문이다. 이 이름을 본 사람들은 "이 메시지가 하는 일을 알아낼 방법이 없겠는걸. 그냥 두는 편이 낫겠어,"라고 생각한다. 그렇다 하더라도 메시지의 사용을 피하기 위한 구실로 삼을 수는 없다. "해당 컬렉션에 실행되는 값을 유지하기"를 원한다면 이 이상한 이름으로 된 메시지를 사용해야 한다.
- 실행 중인 값을 유지하려면 inject:into:를 사용하라. 첫 번째 argument를 초기 값으로 하라. 두 번째 argument는 2 요소(two element) 블록으로 만들어라. 블록 arguments를 "sum"과 "each"라고 불러라. 블록이 실행 값의 다음 값을 평가하도록 하라.
위의 코드는 아래와 같은 모습이 된다:
^self children
inject: 0
into: [:sum :each | sum max: each]
이는 필자가 학습한 inject:into:를 현명하게 사용한 것이다. 필자는 보통 "현명"하단 말을 모욕을 줄 때 사용하지만 여기서는 사용하지 않을 수가 없다. 문제는 컬렉션 내에 근접한 쌍들(pairs)을 반복하는 데에 있다. 즉, 일부 코드에서 요소 1과 2, 요소 2와 3 순으로 평가하고자 한다. 여기에 inject:into:를 사용하는 방법은 다음과 같다:
self children
inject: nil
into:
[:eachPrevious :eachNext |
eachPrevious notNil ifTrue: [...].
eachNext]
처음 루프를 통과할 때 eachPrevious는 nil이고 eachNext는 컬렉션의 첫 번째 요소이다. 조건부 코드는 평가되지 않는다. 전체 블록은 컬렉션의 첫 번째 요소로 평가한다. 두 번째 루프를 통과 시, eachPrevious는 컬렉션의 첫 번째 요소이고 eachNext는 두 번째 요소이다. 조건부 코드가 실행된다. 이는 eachPrevious가 마지막에서 두 번째 요소가 되고 eachNext가 마지막 요소가 될 때까지 계속된다.