SmalltalkBestPracticePatterns:5.4
- 5.4 Collection Idioms (컬렉션 관용구)
Collection Idioms (컬렉션 관용구)
컬렉션 프로토콜을 이용하기가 편해지면 더 높은 수준의 작업을 해결하기 위해 컬렉션의 사용을 시작할 수 있다. 여기 컬렉션이 사용되는 방법 몇 가지를 소개하겠다.
Duplicate Removing Set (중복내용 제거 집합)
Collection(p.115)을 갖고 있다.
- 컬렉션에서 중복내용은 어떻게 제거하는가?
학교에서 이 질문에 답한 기억이 난다. C언어처럼 생긴 코드를 검토하면서 본 기억이 났는데, 언제나 그렇듯 코드는 거추장스럽게 생겼다:
unique := OrderedCollection new.
self owners do: [:each | (unique includes: each) ifFalse:
[unique add: each]]
스몰토크에서는 이제 그럴 필요가 없다. 프로그래밍 또는 실행 시간 시 메모리 할당의 비용이 많이 든다는 생각을 버리고 나면, 그리고 Collection 클래스의 힘에 익숙해지고 나면 이와 같은 문제는 이제 아무 일도 아니였다.
- Collection으로 "asSet"을 전송하라. 그 결과 모든 중복내용이 제거될 것이다.
파란 눈 또는 금발머리를 가진 모든 자녀들을 포함하는ㅡ어떤 자녀들은 둘 다 해당 가능ㅡCollection을 원하는 경우, 이 패턴을 이용할 수 있다.
| nordic |
nordic := (self blueEyedChildren , self
blondHairedChildren) asSet.
...
대학 시절에 스몰토크를 사용했으면 얼마나 좋았을까!
성능을 조절할 때 동일한 요소에 대해 반복적으로 Collections를 생성하고 있음을 발견할 때가 있을 것이다. 그 모든 중간 Collections를 한 번에 구성하는 것은 별로 큰 일이 아니다. 표현식의 명확성을 추구할 것을 명심하라.
유일한 요소들의 정렬이 필요한 경우 Temporarily Sorted Collection(p.155)을 사용하라.
Temporarily Sorted Collection (임시로 정렬된 컬렉션)
Collection(p.115)을 갖고 있다. Comparing Method(p.32)를 구현했으나 객체를 다른 방식으로 정렬하길 원한다.
- Collection을 여러 정렬 중 하나를 이용해 표현하는 방법은?
확실한 해결방식은 애초부터 Collection을 SortedCollection으로서 저장하는 방법이다. 정렬 순서를 변경할 때마다 SortedCollection의 정렬 블록을 변경할 수 있다.
Collection을 달리 정렬하지 않아도 되는 경우에 이 방법을 사용할 경우 문제를 해결하는 것보다 더 많은 문제를 야기한다. 보통은 SortedCollection의 비용을 부담하고 싶지 않을 것이다. 또 여러 개의 정렬 순서를 동시에 원할지도 모른다.
Collection의 복사본을 제출함으로써 문제를 해결할 수 있다. 그런데 Collection으로 추가하거나 그로부터 제거를 조정하는 데에 문제가 발생한다. 이 문제는 동일한 Collection으로 여러 개의 정렬 순서를 관리하는 것보단 훨씬 덜 심각한 편이다.
- "asSortedCollection"을 Collection으로 전송함으로써 Collection의 정렬된 복사본을 리턴하라. 기본 정렬 순서를 원할 경우 "asSortedCollection: aBlock"을 전송하라.
이 패턴은 주로 리스트에 정보를 표현하는 데에 사용되는데, 특히 Duplicate Removing Set(중복내용 제거 집합)를 사용한 경우에 사용된다:
candidateList
^self candidates asSet asSortedCollection: [:a :b | a
name < b name]
Stack (스택)
- 스택을 어떻게 구현하는가?
스몰토크로 입문한 많은 사람들이 처음으로 작성하는 객체들 중 하나가 바로 Stack이다. Stack은 기본 데이터 구조로서, 이론적 프로그래밍 언어에 관한 노래, 이야기, 수백 장에 달하는 연구에서 다뤄 왔다.
스택을 이용하는 알고리즘은 스몰토크에서 간단히 표현된다. 구문(syntax) 자체가 push와 pop과 관련해 작성된, 읽기 쉬운 코드에 도움이 된다. 하지만 기본 이미지에는 Stack 클래스가 존재하지 않는다. 존재하는 것을 여러 번 본 적은 있으나 절대 오래 지속되지 않는 것처럼 보였다. 스몰토크 프로그래머들 또한 스택을 필요로 한다. 스택은 어디에 쓰일까?
OrderedCollection을 이용해 쉽게 스택의 시뮬레이션을 보일 수 있다:
Stack 연산 | Orderedcollection 메시지 |
push | addLast: |
pop | removeLast: |
top | last |
depth | size |
empty | isEmpty |
위의 결과는 스택이 읽는 결과와 완전히 동일하진 않지만 관용구만큼은 인식이 가능하므로 관용구를 사용할 때 비용이 이미지에 새 클래스를 추가하는 비용보다 훨씬 적게 든다.
이렇게 생성된 코드가 혼동스럽지 않은 이유는 무엇일까? OrderedCollection을 스택으로서 이용하여 알고리즘 일부를 쓰고 잊어버린 다음에 그것을 다시 큐(queue)로서 이용하기 시작하는 건 어떤가?
복잡한 알고리즘의 부분이 있다면 그것으로 간단한 public face를 제공하는 객체나 메서드를 만든다. "OrderedCollection이 실제로 스택인지 아는 메서드는 몇 개나 되는가?"라고 질문한다면, 그에 대한 답은 하나다. Role Suggesting Temporary Variable(역할을 제시하는 임시 변수)를 이용해 OrderedCollection이 스택으로 사용되고 있음을 전달하는 것이다.
스몰토크에는 왜 Stack이 없을까? 그 이유는 오로지 OrderedCollection을 이용해 스택을 시뮬레이션하는 문화를 갖고 있기 때문이다. 다른 모든 사람들이 그렇게 하고 해봤자 손해볼 것이 없다면 당신도 그대로 따라하면 되는 것이다.
- OrderedCollection을 이용해 Stack을 구현하라.
자신의 스택을 단일 메서드 내에서만 사용해야하는 경우 임시 변수에 그것을 저장할 수 있다:
| tasks newTask nextTask |
...
...tasks addLast: newTask...
...
...nextTask := tasks removeLast...
...
전체 객체가 스택을 공유해야 하는 경우, Intention Revealing Messages(의도를 알려주는 메시지)로 스택의 존재를 전달하라:
pushTask: aTask
tasks addLast: aTask
popTask
^tasks removeLast
Composed Method(p.21)를 이용하면 OrderedCollections(p.116)을 스택으로 이용해 코드를 간소화하므로 관용구가 혼동되지 않는다.
Queue (큐)
- 큐를 어떻게 구현하는가?
큐와 관련된 글은 스택의 본문과 많이 비슷하다. 모두들 OrderedCollections를 이용해 큐를 시뮬레이션한다:
큐 연산 | OrderedCollection 메시지 |
add | addFirst: |
remove | removeLast: |
empty | isEmpty |
length | size |
스택과 마찬가지로 큐 연산을 OrderedCollection 프로토콜로 전송되는 메시지로 번역하면서 약간 손해보는 바가 있지만 새 클래스를 추가해야 하는 정도는 아니다.
- OrderedCollection을 이용해 큐를 구현하라.
필자는 몇 가지 방식으로 OrderedCollections를 큐로서 사용해왔다. 가장 흔한 방법은 level-order traversal(레벨 순서 순회)을 구현하는 때였는데, 깊이 2 노드 이상을 방문하기 전에 트리의 깊이 1 노드들을 모두 방문해야 하는 구조이다.
코드는 아래와 같은 모양을 띈다:
Node>>levelOrderDo: aBlock
| queue |
queue := OrderedCollection with: self.
[queue isEmpty] whileFalse:
[| current |
current := queue removeLast.
aBlock value: current.
queue addAllFirst: current children]
addFirst:와 removeLast: 대신 add:와 removeFirst:와 함께 SortedCollection를 이용해 priority queue (우선순위 큐)를 구현할 수 있다.
Andrzej 교수님은 벌새만한 체구와 에너지를 지니고 있었지만 체조선수처럼 다부진 체격이었다. 교수님의 수염은 두껍고 까맸고 심한 폴란드 억양을 갖고 있었다. 이름을 한 번만 듣고는 학생들 모두 이름을 부르는 간단한 수법만으로도 우리는 긴장을 늦출 수가 없었다. 당시 그는 헤더 레코드를 이용해 Pascal 언어에서 큐의 연결 리스트(lined list) implementation을 보였다. 처음이었지만 잘 따랐고, 그러다 더 짧은 implementation도 있다는 확신이 생겼다.
머릿속으로 끄덕이다가 푸터 레코드(footer record)를 대신 이용해 종이로 옮겨적은 버전 외에는 아무 것도 보이지 않았다. 아니나 다를까, 루틴들 중 하나로부터 하나의 행의 잘라올 수 있었다. 강의가 끝나고 그것을 시도해보기 위해 컴퓨터 센터로 달려갔다. 빙고! 실행되었다. Andrzej 교수님께 야심찬 작품을 보여드리기 위해 교수실로 달려갔다. 교수님께서 처음에는 회의적이시더니 같이 실행해보고선 나의 성공을 축하해주셨다. 교수님과 일대일로 말을 나눈 건 그때가 처음이었다. 어안이 벙벙했다.
소프트웨어 공학을 위해 컴퓨터 공학은 포기했지만 이 글을 적다보니 그렇게 작은 변형이 얼마나 중요했는지를 다시 경험하게 되었다.
Searching Literal (리터럴 검색)
- 코드를 작성할 때 몇 개의 알려진 리터럴 객체 중 하나를 검색하는 방법은?
스몰토크에 case문이 있었다면 아래와 같이 작성했을 것이다.
Character>>isVowel
case self asLowercase of
$a:
$e:
$i:
$o:
$u: ^true
otherwise: ^false
(case문 구문을 제안하는 바이다. 어찌되었든 대부분의 경우 Choosing Message가 하드 코딩된 case보다 더 낫다.)
스몰토크에는 case문이 없으므로 일반적으로 부울 또는 조건부 로직에 의지한다:
Character>>isVowel
| lower |
lower := self asLowercase.
^lower = $a | (lower = $e) | (lower = $i) | (lower = $o)
| (lower = $u)
물론 아래보다는 낫다:
Character>>isVowel
self = $a | (self = $A) ifTrue: [^true].
self = $e | (self = $E) ifTrue: [^true].
self = $i | (self = $I) ifTrue: [^true].
self = $o | (self = $O) ifTrue: [^true].
self = $u | (self = $U) ifTrue: [^true].
^false
Strings는 리터럴이기 때문에 컬렉션 프로토콜을 이용해 동일한 내용을 훨씬 더 압축적으로 구현할 수 있다:
Character>>isVowel
^'aeiou' includes: self asLowercase
- 당신이 찾고 있는 요소가 포함되어 있는지 리터럴 Collection으로 물어보라.
Symbols도 동일하게 실행할 수 있다:
Style>>isFancy: aSymbol
^#(bold italic) includes: aSymbol
매일 발생하는 기법은 아니지만 코드 몇 행을 줄이는 데에 종종 사용된다.
Lookup Cache (캐시 검색)
- 복잡한 Detect 또는 Select/Reject 루프를 최적화하는 방법은?
가장 단순한 해결방법은 코드를 본래 형태로 유지하는 것이다. 때로는 간단한 속성으로 요소를 찾고 있을 것이다:
childNamed: aString
^self children detect: [:each | each name = aString]
또 어떤 때는 컬렉션에서 요소의 하위집합(subset)을 계산하고 있을 것이다:
childrenWithHairColor: aString
^self children select: [:each | each hairColor = aString]
위의 코드들은 작성하기도, 읽기도 간단하다. 이와 같은 코드가 처음으로 필요하다면 Enumeration과 관련시켜 코드를 작성해야 한다.
규모가 큰 컬렉션이나 자주 계산되는 표현식의 경우, 이러한 계산은 성능의 병목현상을 야기할 수도 있다. 이러한 표현식들 중 하나에서 문제를 발견하는 경우, 계산의 결과를 기억하여(cache), 추후에 다시 계산하는 대신 저장된 결과를 재사용할 수 있을 것이다.
모든 캐시와 마찬가지로, 캐시의 내용을 컬렉션과 그 요소들의 변경내용과 동기화된 채 유지할 수 있을 때에만 이를 실행할 수 있다.
- 비용이 많이 드는 검색 또는 필터 메서드의 이름 앞에 "lookup"을 붙여라. Dictionary를 보유하는 인스턴스 변수를 캐시 결과에 추가하라. 변수의 이름은 검색 이름 앞에 "Cache"를 붙여 명명하라. 검색(search)의 파라미터들을 Dictionary의 키(key)로 만들고, 검색의 결과를 값으로 만들어라.
위의 첫 번째 예제는 "nameCache"이라 불리는 변수로 변한다. 첫 번째, 우리는 본래 메서드를 다음으로 변경한다:
lookupChildNamed: aString
^self children detect: [:each | each name = aString]
Lookup cache(캐시 검색)를 보유하는 변수가 초기화된다고 가정하면 (명시적 초기화와 느긋한 초기화 참고), 우리는 "childNamed:"를 다음으로 재작성한다:
childNamed: aString
^nameCache
at: aString
ifAbsentPut: [self lookupChildNamed: aString]
VisualWorks는 at:ifAbsentPut:를 구현하지 않으며, VisualAge는 객체를 Block이 아니라 두 번째 파라미터로서 취하므로 아래와 같이 작성해야 할 것이다:
childNamed: aString
^nameCache
at: aString
ifAbsent:
[nameCache
at: aString
put: (self lookupChildNamed: aString)]
위에서 두 번째 예제는 Dictionary에 단일 객체 대신 컬렉션을 값으로서 가진다는 점을 제외하면 비슷하게 구현되는 패턴이다:
lookupChildrenWithHairColor: aString
^self children select: [:each | each hairColor = aString]
childrenWithHairColor: aString
^hairColorCache
at: aString
ifAbsentPut: [self lookupChildrenWithHairColor:
aString]
Parsing Stream (스트림 파싱하기)
- 간단한 파서(parser)는 어떻게 작성하는가?
스몰토크에서 간단한 파서를 작성해야 하는 경우가 있을지도 모른다. 컴퓨터 과학자들이 좋아하는 방식, 즉 Ls와 Rs, 그리고 숫자가 이름에 많이 들어가는 유형을 말하는 것이 아니라 각 행마다 키워드가 있고 키워드에 따라 수많은 정보가 있는 간단한 종류를 말한다.
이와 같이 코드를 구성하는 간단한 방식을 한 가지 들자면, 한 번에 한 행을 잡아서 파싱하는 방법이 있다.
parse: aStream
[aStream atEnd] whileFalse: [self parseLine: aStream
nextLine]
parseLine: aString
| reader |
reader := ReadStream on: aString.
keyword := reader nextWord.
keyword = 'one' ifTrue: [self parseOne: reader].
keyword = 'two' ifTrue: [self parseTwo: reader].
...
이는 Strings와 Streams를 곳곳에 생성하는 코드를 생성한다. 무언가 (String 또는 Stream) 각 메서드로 전달되어야 한다. 메서드가 깊이 중첩(nested)된 경우, 그 추가 파라미터는 지루해진다. 오류가 발생하기도 쉬운데 그 이유는 당신이 String을 다루는지, Stream을 다루고 있는지 잊어버리기 십상이기 때문이다.
많은 경우에서 다수의 메서드가 동일한 인스턴스 변수를 공유하는 것은 위험한데, 특히 다수의 메서드에 의해 부수 효과를 받는 객체일 경우 더 위험하다. 한 메서드의 변경은 다른 메서드들에게 무심코 영향을 미치기 쉽다. 하지만 파싱의 경우 제어 흐름의 단순한 특성 때문에 위험이 최소화된다.
- Stream을 인스턴스 변수에 넣어라. 모든 파싱 메서드가 동일한 Stream으로부터 작동하도록 하라.
이 패턴을 이용하면 위의 코드는 아래와 같다:
parse: aStream
reader := aStream. "Reader is now an instance
variable."
[reader atEnd] whileFalse: [self parseLine]
parseLine
keyword := reader nextWord.
keyword = 'one' ifTrue: [^self parseOne].
keyword = 'two' ifTrue: [^self parseTwo].
...
파라미터가 빠지니 코드가 상당히 깔끔하게 정리된다. 각 파싱 메서드는 Parsing Stream을 알려진 상태로 남겨둘 때 주의해야만 다른 메서드들이 String에서 어디에 위치하는지 쉽게 추정할 수 있다.
위의 parseLine에서 conditionals에 의해 형성된 "case문"은 일부 사람들로 하여금 스몰토크에서 "실제" case문을 욕심나게 만들기도 한다. 필자의 경우, 일 년에 한 번 정도 작성해야 한다는 이유로 새로운 언어 특징을 추가해야 한다는 주장은 타당하지 못하다고 생각된다.
Concatenating Stream (스트림 결합하기)
Collecting Temporary Variable(p.105)에 결과를 수집하고 있을 수 있다. Collecting Parameter(p.75)에서 여러 메서드에 걸쳐 결과를 수집하고 있을 수도 있다.
- 어떻게 여러 Collections를 결합하는가?
결합(concatenation)은 여러 Collections를 결합시키는 간단한 방법이다. 하지만 Collections가 많은 경우에는 결합되는 Collection마다 첫 번째 Collection 내 객체들이 복사되기 때문에 속도가 느리다.
Streams는 이러한 딜레마를 해결해준다. Streams는 내용을 여러 번 복사하지 않도록 유의하며, 심지어 그들이 스트리밍(streaming over)하는 Collection조차 커진다.
- 다수의 Collections를 결합 시 Stream을 사용하라.
Collection 결합과 Streams 사이에 차이가 얼마나 유의한지 확인하기 위해 빠른 기준점(benchmark)을 실행했다. 아래와 같이 1,000개 Strings를 결합하는 코드를 실행했다:
100 timesRepeat:
[result := String new.
1000 timesRepeat: [result := result , 'abcdefg']]
실행에는 53초가 소요되었고, 실행이 끝난 후 Stream 버전을 실행했다:
100 timesRepeat:
[writer := WriteStream on: String new.
1000 timesRepeat: [writer nextPutAll: 'abcdefg'].
writer contents]]
총 0.9초가 소요되었다. 약 60배가 차이난다.
때로는 당신의 선택이 아니라 이미 그것을 사용하는 코드에 들어맞다는 이유로 Concatenating Stream을 사용해야 할 것이다. printOn:을 오버라이딩함으로써 객체 인쇄를 특수화할 때 가장 자주 사용된다.