DeepintoPharo:Chapter 14

From 흡혈양파의 번역工房
Jump to navigation Jump to search
제 14장 블록
세부적인 분석

블록: 세부적인 분석

Clément Bera 참여 (bera.clement@gmail.com)


어휘적 유효 범위(lexically-scoped)만 가진 블록 클로저(block closures), 짧게 말해 블록이라는 것은 강력하고 필수적인 스몰토크 기능이다. 블록이 없이는 작고 간편한 구문을 갖기가 힘들 것이다. 스몰토크에서 블록의 사용은 조건문과 루프를 라이브러리 메시지로서 얻어 언어 구문으로 하드코딩하지 않는 데에 핵심이 된다. 이것이 바로 블록이 스몰토크의 구문을 전달하는 메시지와 절대적으로 잘 작용한다고 말할 수 있는 이유다.


뿐만 아니라 블록은 가독성, 재사용 가능성, 코드의 효율성을 향상시키는 데에됴 효과적이다. 하지만 스몰토크의 양호한 동적 런타임 구문은 잘 문서화되지 않는다. 예를 들어, 리턴 문이 있을 때 블록은 escaping 메커니즘과 같이 행동하지만 극단적으로 사용하면 보기 싫은 코드를 야기하므로 이해하는 것이 중요하다.


이번 장에서는 블록 생성 시 변수의 capture와 정의 환경에 대한 중심 개념을 학습할 것이다. 블록이 프로그램 흐름을 어떻게 변경시키는지도 학습할 것이다. 마지막으로 블록을 이해하기 위해 우리는 프로그램이 어떻게 실행하는지, 또 주어진 실행 상태를 나타내는 컨텍스트, 소위 활성 레코드라는 것을 어떻게 표현하는지 설명하고자 한다. 블록 실행 도중에 컨텍스트가 어떻게 사용되는지 보일 것이다. 본 장은 예외(제 13장)에 관한 장을 보충한다. Pharo by Example 저서에서는 블록을 작성하고 사용하는 방법을 제시하였다. 반대로 이번 장은 블록의 좀 더 깊은 측면과 그들의 런타임 행위에 초점을 두겠다.


기본

블록이란 뭘까? 블록은 생성 시 그 환경을 포착(또는 뒤덮는)하는 람다(lambda) 표현식이다. 이것이 정확히 어떤 의미인지는 후에 살펴보겠다. 블록은 익명 함수로서 인식할 수도 있겠다. 블록은 코드 조각으로서, 그 평가는 freeze되어 메시지 사용에 효과가 나타난다. 블록은 사각 괄호로 정의된다.


아래 코드를 실행하고 결과를 출력하면 3이 아니라 하나의 블록을 얻을 것이다. 사실 당신은 블록 값을 요청한 것이 아니라 블록 자체를 요청한 것이므로 블록을 얻게 된다.

[ 1 + 2 ]  [ 1 + 2 ]


블록으로 value 메시지를 전송하면 블록이 평가된다. 좀 더 정밀한 블록은 value (의무적으로 취해야 하는 인자가 없는 경우), value: (블록이 하나의 인자를 필요로 하는 경우), value:value (2개의 인자), value:value:value: (3개의 인자), valueWithArguments:anArray (4개 이상의 인자)를 사용해 평가된다. 이러한 메시지들은 블록 평가에 오래 사용된 기본적인 API이다. 이는 Pharo by Example 서적에서 제시되어 있다.

[ 1 + 2 ] value  3

[ :x | x + 2 ] value: 5  7


몇 가지 간단한 확장(extension)

value 메시지 외에도 Pharo는 cull: 과 같은 유형의 간편한 메시지를 비롯하여 필요 이상의 값이 있을 경우에도 블록의 평가를 지원한다. cull: 은 만일 수신자가 제공되는 것 이상의 인자를 필요로 하는 경우 오류를 발생시킬 것이다. ValueWithPossibleArgs: 메시지는 cull: 과 비슷하지만 블록을 인자로서 전달하기 위해 매개변수의 배열을 취한다. 블록이 제공된 것 이상의 인자를 필요로 할 경우 valueWithPossibleArgs: 는 그것을 nil로 채울 것이다.

[ 1 + 2 ] cull: 5  3
[ 1 + 2 ] cull: 5 cull: 6  3
[ :x | 2 + x ] cull: 5  7
[ :x | 2 + x ] cull: 5 cull: 3  7
[ :x :y | 1 + x + y ] cull: 5 cull: 2  8
[ :x :y | 1 + x + y ] cull: 5   error because the block needs 2 arguments.
[ :x :y | 1 + x + y ] valueWithPossibleArgs: #(5)
     error because 'y' is nil and '+' does not accept nil as a parameter.


그 외 메시지들. 평가의 프로파일링에 유용한 메시지도 몇 가지가 있다 (상세한 정보는 제 17장을 참고).

bench. 5초 이내에 수신자 블록을 평가할 수 있는 횟수를 리턴한다.
durationToRun. 수신자 블록을 평가하는 데에 들인 시간을 (Duration의 인스턴스) 응답한다.
timeToRun. 해당 블록을 평가하는 데에 들인 시간을 밀리초로 응답한다.


일부 메시지는 오류 처리와 관련된다 (제 13장에서 설명한 바와 같이).

ensure: terminationBlock. 수신자의 평가가 완료되었는지와 상관없이 수신자를 평가한 후 종료(termination) 블록을 평가한다.
ifCurtailed: onErrorBlock. 수신자를 평가하며, 평가가 완료되지 않은 경우 오류 블록을 평가한다. 수신자의 평가가 정상적으로 완료되면 오류 블록은 평가되지 않는다.
on: exception do: catchBlock. 수신자를 평가한다. exception 예외가 발생하면 catch 블록을 평가한다.
on: exception fork: catchBlock. 수신자를 평가한다. exception 예외가 발생하면 오류를 처리하게 될 새로운 프로세스를 fork한다. 원래 프로세스는 마치 수신자 평가가 끝나고 nil을 응답할 때와 마찬가지로 계속해서 실행될 것인데, 가령 [ self error: 'some error'] on: Error fork: [:ex | 123 ] 와 같은 표현식은 원본 프로세스에게 nil을 응답할 것이다. 컨텍스트 스택, 즉 해당 메시지를 수신자로 전송한 컨텍스트부터 시작해 스택의 최상위까지가 forked 프로세스로 전송될 것이며, catch 블록은 최상단에 위치할 것이다. 마지막으로 catch 블록은 forked 프로세스에서 평가될 것이다.


일부 메시지들은 프로세스 스케줄링과 연관된다. 본문에서는 가장 중요한 메시지만 열거하겠다. 본 장은 Pharo에서 동시적 프로그래밍에 관한 내용이 아니므로 깊이 다루지 않을 것이다.

fork. 수신자를 평가하는 Process를 생성하고 schedule한다.
forkAt: aPriority. 주어진 우선순위에서 수신자를 평가하는 Process를 생성하고 schedule한다. 새로 생성된 프로세스를 응답한다.
newProcess. 수신자를 평가하는 Process를 응답한다. 프로세스는 schedule되지 않는다.


변수와 블록

블록은 고유의 임시 변수를 가질 수 있다. 그러한 변수들은 각 블록 평가 도중에 초기화되며 블록에 국부적이다. 그러한 변수가 어떻게 유지되는지는 후에 살펴볼 것이다. 지금은 블록이 다른 (non-local) 변수들을 참조할 때 어떤 일이 일어나는지를 분명히 하는 것이 문제다. 블록은 그것이 사용하는 외부 변수를 둘러쌀 것이다. 즉, 블록이 사용하는 변수를 어휘적으로 포함하지 않는 환경에서 블록을 후에 실행하더라도 블록은 여전히 실행 도중에 변수로 접근성을 가질 것을 의미한다. 지역 변수를 구현하고 컨텍스트를 이용해 보관하는 방법은 후에 제시하겠다.


Pharo에서 private 변수들은 (self, 인스턴스 변수, 메서드 임시 변수와 인자) 어휘적 유효 범위만 가진다 (즉, 메서드 내 표현식은 가령 클래스의 인스턴스 변수로 접근할 수 있으나 다른 클래스 내의 동일한 표현식은 동일한 변수로 접근할 수 없다). 런타임 시 이러한 변수들은 블록이 평가되는 컨텍스트가 아니라 변수들을 포함하는 블록이 정의된 컨텍스트에서 바인딩된다 (그들과 연관된 값을 얻는다). 즉, 블록은 다른 곳에서 평가될 경우 블록이 생성될 당시 그 범위 내에서 (블록에게 표시) 변수로 접근할 수 있다. 관습상 블록이 정의되는 컨텍스트는 블록 홈 컨텍스트(block home context)라고 명명된다.


블록 홈 컨텍스트란 특정 실행 포인트를 나타내므로 (애초에 블록을 생성한 프로그램 실행이므로) 블록 홈 컨텍스트라는 개념은 프로그램 실행을 표현하는 객체에 의해 표현되는데, 스몰토크에서는 컨텍스트에 해당하겠다. 본질적으로 컨텍스트(다른 언어로 스택 프레임 또는 활성 레코드라 불리는)는 현재 평가 단계가 실행되는 컨텍스트, 다음으로 실행될 바이트 코드, 임시 변수의 값과 같이 현재 평가 단계에 관한 정보를 나타낸다. 컨텍스트는 스몰토크 실행 스택 요소를 표현하는 활성 레코드이다. 이는 중요한 내용으로, 해당 개념은 후에 다시 살펴볼 것이다.


블록은 어떤 컨텍스트(실행 지점을 표현하는 객체) 내부에서 생성된다.


몇 가지 작은 실험

변수가 블록에서 어떻게 바인딩되는지 이해하기 위해 실험을 약간 해보도록 하자. Bexp(BlockExperiment)로 명명된 클래스를 정의한다.

Object subclass: #Bexp
    instanceVariableNames: ''
    classVariableNames: ''
    poolDictionaries: ''
    category: 'BlockExperiment'


실험 1: 변수 검색. 변수는 블록 정의 컨텍스트에서 검색된다. 우리는 두 개의 메서드를 정의하는데, 하나는 변수 t를 정의하여 42로 설정하는 메서드, 나머지 하나는 다른 곳에서 정의된 블록을 실행하는 새 변수를 정의한다.

Bexp>>setVariableAndDefineBlock
    | t |
    t := 42.
    self evaluateBlock: [ t traceCr ]

Bexp>>evaluateBlock: aBlock
    | t |
    t := nil.
    aBlock value

Bexp new setVariableAndDefineBlock
     42


Bexp new setVariableAndDefineBlock 표현식을 실행하면 Transcript에 (메시지 raceCr) 42를 출력한다. 블록이 메서드 실행 도중에 평가된다 하더라도 evaluateBlock: 메서드에 정의된 것보다는 setVariableAndDefineBlock 메서드에 정의된 임시 변수 t 의 값이 사용된다. 변수 t는 블록 생성 컨텍스트에서 검색된다 (컨텍스트는 블록 평가 메서드 evaluateBlock: 의 컨텍스트가 아니라 setVariableAndDefineBlock 메서드의 실행 도중에 생성).


이제 세부적으로 살펴보자. 그림 14.1은 Bexp new setVariableAndDefineBlock 표현식의 실행을 보여준다.

  • setVariableAndDefineBlock 메서드의 실행 중 t 변수가 정의되고 42가 할당된다. 이후 블록이 생성되며, 이 블록은 임시 변수(단계 1)를 보유하는 메서드 활성 컨텍스트를 참조한다.
  • EvaluteBlock: 메서드는 블록에 있는 것과 같은 이름으로 된 고유의 지역 변수 t를 정의한다. 하지만 이는 블록이 평가될 당시 사용되는 변수는 아니다. EvaluateBlock: 메서드를 실행하는 동안 블록이 평가되고 (단계 2), t traceCr 표현식이 실행되는 동안 non-local 변수 t가 블록의 홈 컨텍스트에서 검색되는데, 이는 동시에 실행되는 메서드의 컨텍스트가 아니라 블록을 생성한 메서드 컨텍스트에 해당하겠다.


그림 14.1: 블록이 평가된 곳이 아니라 블록이 생성된 메서드 활성 컨텍스트에서 non-local 변수가 검색된다.


Non-local 변수들은 블록을 실행하는 컨텍스트가 아니라 블록의 홈 컨텍스트(예: 블록을 생성한 메서드 컨텍스트)에서 검색된다.


실험 2: 변수 값 변경하기. 우리의 실험을 계속해보자. SetVariableAndDefineBlock2 메서드는 블록의 평가 도중에 non-local 변수 값을 변경할 수 있음을 보여준다. Bexp new setVariableAndDefineBlock2 를 실행하면 33을 출력하는데, 33은 변수 t의 마지막 값이기 때문이다.

Bexp>>setVariableAndDefineBlock2
    | t |
    t := 42.
    self evaluateBlock: [ t := 33. t traceCr ]

Bexp new setVariableAndDefineBlock2
     33


실험 3: 공유 non-local 변수로 접근하기. 두 개의 블록이 non-local 변수를 공유할 수 있고, 서로 다른 순간에 해당 변수의 값을 수정할 수 있다. 이를 확인하려면 아래와 같이 새 메서드 setVariableAndDefineBlock3을 정의해보자.

Bexp>>setVariableAndDefineBlock3
    | t |
    t := 42.
    self evaluateBlock: [ t traceCr. t := 33. t traceCr ].
    self evaluateBlock: [ t traceCr. t := 66. t traceCr ].
    self evaluateBlock: [ t traceCr ]

Bexp new setVariableAndDefineBlock3
     42
     33
     33
     66
     66


Bexp new setVariableAndDefineBlock3 는 42, 33, 33, 66, 66를 출력할 것이다. 여기서 두 개의 블록 [ t := 33. t traceCr ] 와 [ t := 66. t traceCr ] 는 동일한 변수 t로 접근 및 수정할 수 있다. EvaluateBlock: 메서드가 첫 번째로 실행되는 동안 그 현재 값인 42가 출력되고, 이후에 값이 변경 및 출력된다. 두 번째 호출에도 비슷한 상황이 발생한다. 이 예제는 변수가 보관된 위치를 블록들이 공유하며, 블록은 capture된 변수의 값을 복사하지 않음을 보여준다. 그저 변수의 위치를 참조하는데, 여러 개의 블록이 동일한 위치를 참조할 수 있다.


실험 4: 변수 검색은 실행 시간에 이루어진다. 아래 예제는 변수의 값이 런타임 시 검색되며, 블록 생성 중에 복사되지 않음을 보여준다. 먼저 Bexp 클래스에 인스턴스 변수 block 을 추가한다.

Object subclass: #Bexp
    instanceVariableNames: 'block'
    classVariableNames: ''
    poolDictionaries: ''
    category: 'BlockExperiment'


여기서 변수 t의 초기 값은 42다. 블록은 생성된 후 인스턴스 변수 block에 보관되지만 t에 대한 값은 블록이 평가되기 전에 69로 변경된다. 그리고 이것은 효과적으로 출력되는 마지막 값(69)인데, 그 이유는 실행 시에 검색되기 때문이다. Bexp new setVariableAndDefineBlock4 을 실행하면 69가 출력된다.

Bexp>>setVariableAndDefineBlock4
    | t |
    t := 42.
    block := [ t traceCr: t ].
    t := 69.
    self evaluateBlock: block

Bexp new setVariableAndDefineBlock4
     69.


실험 5: 메서드 인자에 관해. 자연적으로 우리는 메서드 인자는 정의하는 메서드의 컨텍스트에 바인딩될 것으로 예상할 것이다. 이 점을 설명해보자. 아래 메서드를 정의하라.

Bexp>>testArg
    self testArg: 'foo'.

Bexp>>testArg: arg
    block := [arg crLog].
    self evaluateBlockAndIgnoreArgument: 'zork'.

Bexp>>evaluateBlockAndIgnoreArgument: arg
    block value.


이제 Bexp new testArg: 'foo' 를 실행하면 'foo' 를 출력하며, evaluateBlockAndIgnoreArgument: 메서드에서 임시 arg가 재정의되더라도 마찬가지다.


실험 6: 자체 바인딩(self binding). 이제 self도 capture되는지가 궁금할 수 있다. 이를 테스트하기 위해선 다른 클래스가 필요하다. 간단히 새 클래스와 몇 개의 메서드를 정의해보자. 인스턴스 변수 x를 클래스 Bexp 로 추가하고 아래와 같이 initialize 메서드를 정의해보자.

Object subclass: #Bexp
    instanceVariableNames: 'block x'
    classVariableNames: ''
    poolDictionaries: ''
    category: 'BlockExperiment'

Bexp>>initialize
    super initialize.
    x := 123.


Bexp2라는 클래스를 하나 더 정의하라.

Object subclass: #Bexp2
    instanceVariableNames: 'x'
    classVariableNames: ''
    poolDictionaries: ''
    category: 'BlockExperiment'

Bexp2>>initialize
    super initialize.
    x := 69.

Bexp2>>evaluateBlock: aBlock
    aBlock value


다음으로 Bexp2에 정의된 메서드를 호출하게 될 메서드를 정의하라.

Bexp>>evaluateBlock: aBlock
    Bexp2 new evaluateBlock: aBlock

Bexp>>evaluateBlock
    self evaluateBlock: [self crTrace ; traceCr: x]

Bexp new evaluateBlock
     a Bexp123 "and not a Bexp269"


이제 Bexp new evaluateBlock 를 실행하면 Transcript에Bexp123 이 출력되면서 블록이 self 또한 capture하였음을 표시하는데, Bexp2의 인스턴스가 블록을 평가하였지만 출력된 객체(self)는 블록 생성 시 접근 가능한 원본 Bexp 인스턴스이기 때문이다.


결론. 블록이 실행되는 컨텍스트가 아니라 블록이 정의되는 컨텍스트에서 접근하는 변수를 블록이 capture함을 보여준다. 블록은 다수의 블록이 공유 가능한 변수 위치로 참조를 유지한다.


Block-local 변수

앞서 살펴보았듯 블록은 그것이 정의된 장소에 연결된 어휘적 클로저다. 다음으로는 블록 지역 변수가 그들의 생성에 대한 실행 컨텍스트 연계(link)에서 할당된다는 사실을 보임으로써 이러한 연결을 설명할 것이다. 변수가 블록에 국부적(local)일 때와 메서드에 국부적일 때 차이를 설명하겠다 (그림 14.2 참고).

블록 할당. 아래의 blockLocalTemp 메서드를 구현하라.

Bexp>>blockLocalTemp
    | collection |
    collection := OrderedCollection new.
    #(1 2 3) do: [ :index |
        | temp |
        temp := index.
        collection add: [ temp ] ].
    ^ collection collect: [ :each | each value ]
DeepintoPharo Image 14-2-a.jpg DeepintoPharo Image 14-2-b.jpg
그림 14.2: blockLocalTemp execution (왼쪽) - blockOutsideTemp execution (오른쪽)


코드에 대해 언급해보자. 우리는 루프에서 생성된 임시 변수 temp로 현재 색인을 보관하는 루프를 생성한다. 이후 이 변수로 접근하는 블록을 컬렉션에 보관한다. 루프 다음에 우리는 접근하는 블록마다 평가하고 값의 컬렉션을 리턴한다. 해당 메서드를 실행하면 1, 2, 3이 있는 컬렉션을 얻는다. 이러한 결과는 컬렉션 내 각 블록이 서로 다른 temp 변수를 참조함을 보여준다. 이는 각 블록 생성(각 루프 단계에서)마다 실행 컨텍스트가 생성되고 블록[temp]이 이러한 컨텍스트를 참조한다는 사실 때문에 발생하는 것이다.


메서드 할당. temp가 블록 변수가 아니라 메서드 변수라는 것만 제외하고 BlockLocalTemp 와 동일한 새 메서드를 생성하라.

Bexp>>blockOutsideTemp
    | collection temp |
    collection := OrderedCollection new.
    #(1 2 3) do: [ :index |
        temp := index.
        collection add: [ temp ] ].
    ^ collection collect: [ :each | each value ]


blockOutsideTemp 메서드를 실행하면 이제 3, 3, 3이 있는 컬렉션을 얻는다. 이러한 결과는 컬렉션 내 각 블록이 이제는 blockOutsideTemp 컨텍스트에 할당된 단일 변수 temp를 참조하여 temp가 블록들에 의해 공유되는 사실로 이어짐을 보여준다.


변수는 그들을 정의한 메서드보다 오래 생존할 수 있다

블록에 의해 참조되는 non-block 지역 변수는 메서드 실행이 종료되더라도 다른 표현식에서 접근 및 공유할 수 있다. 이것은 변수가 그들을 정의한 메서드 실행보다 오래 생존한다고 말한다. 예를 몇 가지 들어보겠다.


메서드와 블록 간 공유. 메서드와 블록 간에 변수가 공유됨을 보여주는 (앞의 실험과 마찬가지로) 간단한 예로 시작하겠다. 임시 변수 a를 정의하는 메서드 foo를 정의해보자.

Bexp>>foo
    | a |
    [ a := 0 ] value.
    ^ a

Bexp new foo
     0


Bexp new foo를 실행하면 nil이 아니라 0을 얻는다. 여기서 보이는 값은 메서드 body와 블록에서 공유하는 값이다. 메서드 body 내부에서 우리는 블록 평가에 의해 값이 설정된 변수로 접근이 가능하다. 메서드 body와 블록 body 모두 동일한 임시 변수 a로 접근한다.


조금만 더 복잡하게 만들어보자. 아래와 같이 twoBlockArray 메서드를 정의하라.

Bexp>>twoBlockArray
    | a |
    a := 0.
    ^ {[ a := 2] . [a]}


twoBlockArray 메서드가 임시 변수 a를 정의한다. 또한 a의 값을 0으로 설정하고, a의 값을 2로 설정하는 블록을 첫 번째 요소로 하고, 임시 변수 a의 값을 리턴하기만 하는 블록을 두 번째 요소로 하는 배열을 리턴한다.


이제 twoBlockArray가 리턴하는 배열을 보관하고, 배열에 보관된 블록을 평가한다. 이는 아래 코드를 통해 이루어진다.

| res |
res := Bexp new twoBlockArray.
res second value.  0
res first value.
res second value.  2


아니면 아래와 같이 코드를 정의하고 transcript를 열어 결과를 확인하는 방법도 있다.

| res |
res := Bexp new twoBlockArray.
res second value traceCr.
res first value.
res second value traceCr.


잠시 물러나서 중요한 요점을 살펴보자. 앞의 코드 조각에서는 res second value 과 res first value 표현식이 실행되면 twoBlockArray 메서드는 이미 실행을 끝낸 상태로 더 이상 실행 스택에 남아 있지 않다. 여전히 임시 변수 a로 접근하여 새 값으로 설정할 수는 있다. 이러한 실험을 통해 우리는 블록에 의해 참조되는 변수가 그들을 참조하는 블록을 생성한 메서드보다 오래 생존할 수 있다는 점이다. 따라서 변수가 그들을 정의하는 메서드의 실행보다 오래 생존한다고 말한다.


이번 예제에서 임시 변수는 어떻게든 활성 컨텍스트에 보관되는데 구현은 그보다 좀 더 미묘하다는 사실을 확인할 수 있다. 블록 구현은 실행 스택에 있지 않고 heap 에서 상주하는 구조에 참조(referenced) 변수를 유지할 필요가 있다.


블록 내부로부터 리턴하기

이번 절에서는 당신이 인스턴스 변수로 전달하거나 인스턴스 변수에 보관한 블록 내부에 (예: [^ 33]) 리턴 문을 갖는 것이 왜 바람직하지 않은지를 설명하겠다. 명시적 리턴 문이 있는 블록을 non-local returning block이라 부른다. 몇 가지 기본 요점을 먼저 설명하겠다.

리턴에 관한 기본 내용

기본적으로 리턴된 메서드의 값은 메시지의 수신자에 해당하는데 가령 self를 예로 들 수 있겠다. 리턴 표현식(문자 ^로 시작되는 표현식)은 메시지의 수신자가 아닌 값도 리턴하도록 허용한다. 뿐만 아니라 리턴 문의 실행은 현재 실행된 메서드를 나가서(exit) 그 호출자에게 리턴한다. 이는 리턴 문 다음에 오는 표현식을 무시한다.


실험 7: 리턴의 기존 행위. 아래 메서드를 정의하라. Bexp new testExplicitReturn 을 실행하면 'one' 과 'two'를 출력하지만 not printed는 출력하지 않을 것인데, testExplicitReturn 메서드가 이전에 리턴했을 것이기 때문이다.

Bexp>>testExplicitReturn
    self traceCr: 'one'.
    0 isZero ifTrue: [ self traceCr: 'two'. ^ self].
    self traceCr: 'not printed'


리턴 표현식은 블록 body에서 마지막 문이어야 함을 주목하라.


non-local 리턴의 행위 escape하기

표현식 흐름은 현재 호출하는 메서드로 곧바로 뛰어들 것이기 때문에 리턴 표현식은 escaping 메커니즘과도 같다. 이러한 행위를 설명하기 위해 아래와 같이 jumpingOut 이라는 새 메서드를 정의해보자.

Bexp>>jumpingOut
    #(1 2 3 4) do: [:each |
        self traceCr: each printString.
        each = 3
            ifTrue: [^ 3]].
    ^ 42

Bexp new jumpingOut
     3


예를 들어 표현식 Bexp new jumpingOut은 42가 아니라 3을 리턴할 것이다. ^42 는 절대 도달되지 않을 것이다. 표현식 [^3]은 깊이 중첩(nested)될 수 있으며, 그 실행은 모든 수준을 빠져나와(jump out) 메서드 호출자에게 리턴한다. 일부 오래된 코드(앞의 예외를 소개한 바 있음)는 non-local returning 블록을 전달하여 복잡한 흐름을 야기하고 코드의 유지가 힘들게 된다. 이러한 스타일은 사용하지 않을 것을 권하는데, 복잡한 코드와 버그로 이어지기 때문이다. 다음 절에서는 return이 실제로 리턴하는 경우를 주의 깊게 살펴볼 것이다.


리턴 이해하기

리턴이 사실상 현재 실행을 escape 하는지를 확인하기 위해 약간 더 복잡한 호출 흐름을 빌드하겠다. 4개의 메서드를 정의할 것인데, 그 중 하나(defineBlock)는 escaping 블록을 생성하고, 하나(arg:)는 이 블록을 평가하며, 다른 하나(evaluatingBlock:)는 블록을 실행한다. 리턴의 escaping 행위를 강조하기 위해 우리는 evaluatingBlock: 을 정의하여 그 인자의 평가가 끝나면 무한 루프에 빠지게 됨을 주목하라.

Bexp>>start
    | res |
    self traceCr: 'start start'.
    res := self defineBlock.
    self traceCr: 'start end'.
    ^ res

Bexp>>defineBlock
    | res |
    self traceCr: 'defineBlock start'.
    res := self arg: [ self traceCr: 'block start'.
        1 isZero ifFalse: [ ^ 33 ].
        self traceCr: 'block end'. ].
    self traceCr: 'defineBlock end'.
    ^ res

Bexp>>arg: aBlock
    | res |
    self traceCr: 'arg start'.
    res := self evaluateBlock: aBlock.
    self traceCr: 'arg end'.
    ^ res

Bexp>>evaluateBlock: aBlock
    | res |
    self traceCr: 'evaluateBlock start'.
    res := self evaluateBlock: aBlock value.
    self traceCr: 'evaluateBlock loops so should never print that one'.
    ^ res


Bexp new start 를 실행하면 아래와 같이 출력된다 (호출의 흐름을 강조하기 위해 들여쓰기를 적용하였다).

start start
    defineBlock start
        arg start
            evaluateBlock start
                block start
start end


호출하는 메서드 start가 완전히 실행되었음을 확인할 수 있다. 메서드 defineBlock은 완전히 실행되지 않았다. 사실 그 escaping block[^33]은 메서드 evaluateBlock:에서 두 번의 호출만큼 떨어져 실행된다. 블록의 평가는 블록 홈 컨텍스트 전송자(예: 블록을 생성한 메서드를 호출한 컨텍스트)로 리턴한다.


블록의 리턴 문이 evaluateBlock: 메서드에서 실행되면 실행은 보류 중(pending)인 계산을 버리고 블록의 홈 컨텍스트를 생성한 메서드 실행 포인트로 리턴한다. 블록은 defineBlock 메서드에서 정의된다. 블록의 홈 컨텍스트는 defineBlock 메서드의 정의를 표현하는 활성 컨텍스트다. 따라서 리턴 표현식은 defineBlock 실행 직후에 start 메서드 실행으로 리턴한다. 그것이 바로 arg: 와 evaluateBlock: 의 보류 중인 실행이 파기되는 이유이며, start 메서드의 실행이 끝나는 것을 목격하는 이유이다.

그림 14.3: non-local 리턴 실행의 블록은 블록 홈 컨텍스트를 활성화한 메서드 실행으로 리턴한다. 프레임은 컨텍스트를 표현하고, 점선은 다른 실행 시간에서 동일한 블록을 나타낸다.


그림 14.3에 나타난 바와 같이 [^33]은 그것의 홈 컨텍스트 텍스트의 전송자에게 리턴할 것이다. [^33] 홈 컨텍스트는 defineBlock 메서드의 실행을 표현하는 컨텍스트이므로 그 결과를 start 메서드로 리턴할 것이다.

  • Step 1은 defineBlock 메서드의 호출이 이루어지기까지 실행을 나타낸다. Trace 'start start'가 출력된다.
  • Step 3는 Step 2에서 이루어지는 블록 생성까지의 실행을 나타낸다. 블록의 홈 컨텍스트는 defineBlock 메서드 실행 컨텍스트다.
  • Step 4는 메서드 호출까지의 실행을 의미한다.
  • Step 5는 블록 평가까지의 실행을 나타낸다. 'evaluateBlock start'가 출력된다.
  • Step 6은 조건문까지의 블록 실행을 나타내며, 'block start'가 출력된다.
  • Step 7은 리턴 문까지의 실행을 나타낸다.
  • Step 8은 리턴 문의 실행을 표현한다. 이는 블록 홈 컨텍스트의 전송자로 리턴하는데, 가령 start 메서드에서 defineBlock 메서드의 호출 직후를 예로 들 수 있겠다. 실행은 계속되고 'start end'가 출력된다.


Non local return [^ ...]은 블록 홈 컨텍스트의 전송자로 리턴한다. 즉, 블록을 생성한 메서드를 호출한 메서드 실행 포인트로 리턴한다는 의미다.


정보 접근하기. 블록의 홈 컨텍스트를 수동으로 확인하고 찾기 위해서는 다음을 실행할 수 있다. thisContext home inspect 표현식을 defineBlock 메서드의 블록에 추가한다. 현재 실행 컨텍스트를 통해 클로저로 접근하고 그 홈 컨텍스트를 얻는 thisContext closure home inspect 표현식을 추가할 수도 있다. 두 가지 경우에서 만일 evaluteBlock: 메서드의 실행 도중에 블록이 평가된다 하더라도 블록의 홈 컨텍스트는 defineBlock 메서드라는 사실을 주목한다.


그러한 표현식은 블록 평가 중에 실행될 것이다.

Bexp>>defineBlock
    | res |
    self traceCr: 'defineBlock start'.
    res := self arg: [ thisContext home inspect.
        self traceCr: 'block start'.
        1 isZero ifFalse: [ ^ 33 ].
        self traceCr: 'block end'. ].
    self traceCr: 'defineBlock end'.
    ^ res


실행이 어디서 끝나는지 확인하기 위해서는 thisContext home sender copy inspect 표현식을 사용할 수 있는데, 이를 사용하면 start 메서드에서 할당(assignment)을 가리키는 메서드 컨텍스트를 리턴한다.


몇 가지 추가 예제. 아래 예제들은 escaping 블록이 그들의 홈 컨텍스트의 전송자로 jump함을 보여준다. 예를 들어, 앞의 예제들은 메서드 start가 완전히 실행되었음을 보였다. valuePassingEscapingBlock을 아래와 같이 BlockClosure 클래스에 정의한다.

BlockClosure>>valuePassingEscapingBlock
    self value: [ ^nil ]


그리고 메서드 인자가 false인 경우 메서드가 오류를 발생한다는 간단한 assert를 정의한다.

Bexp>>assert: aBoolean
    aBoolean ifFalse: [Error signal]


다음으로 아래 메서드를 정의한다.

Bexp>>testValueWithExitBreak
    | val |
    [ :break |
        1 to: 10 do: [ :i |
            val := i.
            i = 4 ifTrue: [ break value ] ] ] valuePassingEscapingBlock.
    val traceCr.
    self assert: val = 4.


해당 메서드는 루프의 step 4에 도달하는 즉시 break 인자가 평가되는 블록을 정의한다. Bexp new testValueWithExitBreak의 실행은 오류를 발생하지 않고 실행되고, Transcript 상에 4를 출력한다. 이로써 루프가 중지되었고, 값이 출력되었으며, 주장이 확인되었다.


위의 testValueWithExitBreak 메서드에서 value:[^ nil]이 전송한 valuePassingEscapingBlock 메시지를 변경할 경우 testValueWithExitBreak 메서드의 실행이 종료될(exit) 것이기 때문에 당신은 블록이 평가될 때 추적(trace)을 얻지 못할 것이다. 이러한 경우 valuePassingEscapingBlock을 호출하는 것은 value:[^nil]을 호출하는 것과 동일하지 않은데, escaping block[^nil]의 홈 컨텍스트가 다르기 때문이다. 원본 valuePassingEscapingBlock을 이용하면 블록 [^nil]의 홈 컨텍스트는 testValueWithExitContinue 메서드 자체가 아니라 valuePassingEscapingBlock에 해당한다. 따라서 평가할 경우 escaping 블록은 실행 흐름을 testValueWithExitBreak 내의 valuePassingEscapingBlock 메시지로 변경한다 (흐름이 defineBlock 메시지의 호출 직후로 돌아왔던 앞의 예제와 비슷하게). assert: 앞에 self halt를 놓으면 확신할 수 있을 것이다. 우리 예제에서는 halt에 도달하겠지만 다른 경우는 그렇지 않을 것이다.


Non-local 리턴 블록. 블록은 항상 자신의 홈 컨텍스트에서 평가되기 때문에 이미 리턴된 메서드 실행으로부터 리턴을 시도하는 것이 가능하다. 이러한 런타임 오류 상태는 가상 머신에 의해 잡힌다.

Bexp>>returnBlock
    ^ [ ^ self ]

Bexp new returnBlock value   Exception


returnBlock을 실행하면 메서드는 그 호출자에게 (여기서는 최상위 수준 실행) 블록을 리턴한다. 블록을 평가할 때는 블록을 정의하는 메서드가 이미 종료되었고 블록은 일반적으로 블록 홈 컨텍스트의 전송자에게 리턴하는 리턴 표현식을 포함하고 있으므로 오류가 시그널링된다.


결론. non-local 표현식([^ ...])이 있는 블록은 블록 홈 컨텍스트의 전송자로 리턴한다 (컨텍스트는 블록 생성으로 이어지는 실행을 나타낸다).


컨텍스트: 메서드 실행 표현하기

변수를 검색할 때 블록은 홈 컨텍스트를 참조함을 확인했다. 따라서 이제는 컨텍스트를 살펴볼 것이다. 컨텍스트는 프로그램 실행을 표현한다. Pharo 실행 엔진은 아래의 정보가 있는 현재 실행 상태를 나타낸다.

  1. 바이트코드가 실행 중인 CompiledMethod
  2. 그 CompiledMethod에서 실행될 다음 바이트코드의 위치. 이는 해석기(interpreter)의 프로그램 포인터다.
  3. CompiledMethod를 호출한 메시지의 인자와 수신자.
  4. CompiledMethod가 필요로 하는 임시 변수.
  5. 콜 스택.


Pharo에서 MethodContext 클래스는 이러한 실행 정보를 나타낸다. MethodContext 인스턴스는 특정 실행 포인트에 관한 정보를 보유한다. Pseudo-variable thisContext는 현재 실행 포인트로의 접근성을 제공한다.


Contexts와 상호작용하기

예를 들어보도록 하겠다. 우선 Bexp new first: 33을 이용해 아래 메서드를 정의하고 실행하라.

Bexp>>first: arg
    | temp |
    temp := arg*2.
    thisContext copy inspect.
    ^ temp

그림 14.4: 주어진 실행 포인트에서 임시 변수 temp의 값으로 접근할 수 있는 메서드 컨텍스트.


그림 14.4에서 보이는 바와 같이 인스펙터를 얻을 것이다. 가상 머신은 컨텍스를 재사용함으로써 메모리 소모량을 제한하기 때문에 우리는 thisContext를 이용해 얻은 현재 컨텍스트를 복사함을 명시한다.


MethodContext는 메서드 실행의 활성 컨텍스트를 나타낼 뿐만 아니라 블록에 대해서도 나타낸다. 현재 컨텍스트의 값을 몇 가지 살펴보자.

  • sender는 현재 컨텍스트의 생성을 야기한 이전 컨텍스트를 가리킨다. 여기서 표현식을 실행하면 컨텍스트가 생성되고, 이 컨텍스트는 현재 컨텍스트의 전송자가 된다.
  • method는 현재 실행 중인 메서드를 가리킨다.
  • pc 는 마지막으로 실행된 명령어에 대한 참조를 유지한다. 여기서 그 값은 27이다. 어떤 명령어가 참조되는지를 확인하려면 method 인스턴스 변수를 더블 클릭하고 all bytecodes 필드를 선택하면 그림 14.5에 표시된 바와 같이 다음으로 실행될 명령어는 pop임을 (명령어 28) 알 수 있다.
  • stackp는 컨텍스트에서 변수의 스택 깊이를 정의한다. 대부분의 경우 그 값은 보관된 임시 변수(인자 포함)의 개수가 된다. 하지만 특정 경우, 가령 메시지 전송 중인 경우 스택의 깊이가 증가하여 수신자가 밀리고 (pushed) 나서 인자가 밀리고, 마지막으로 메시지 전송이 실행되어 스택의 깊이는 이전 값으로 복귀된다.
  • closureOrNil은 현재 실행 중인 클로저 또는 nil로의 참조를 보유한다.
  • receiver는 메시지 수신자를 의미한다.


MethodContext 클래스와 그 슈퍼클래스들은 특정 컨텍스트에 관한 정보를 얻기 위한 다수의 메서드를 정의한다. 예를 들어, tempNamed: 를 전송함으로써 특정 임시 변수의 값을 얻고, arguments 메시지를 전송함으로써 인자의 값을 얻을 수 있다.


블록 중첩과 컨텍스트

이제 중첩 블록의 사례와 그것이 홈 컨텍스트에 미치는 영향에 대해 살펴보자. 사실상 블록은 그것이 생성된 컨텍스트를 가리키는데, 이러한 컨텍스트를 외부 컨텍스트(outer context)라고 한다. 상황에 따라 블록의 외부 컨텍스트는 블록의 홈 컨텍스트가 되기도 하고 그렇지 않기도 하다. 복잡하지 않다. 각 블록은 어떤 컨텍스트 내부에서 생성된다는 말이다. 이것이 블록의 외부 컨텍스트다. 블록은 이러한 외부 컨텍스트의 내부에서 직접적으로 생성된다. 홈 컨텍스트는 메서드 수준에서의 컨텍스트다. 블록이 중첩되지 않은 경우 외부 컨텍스트도 블록 홈 컨텍스트가 된다.


하지만 블록이 다른 블록 실행 내부에서 중첩될 경우 외부 컨텍스트는 블록 실행 컨텍스트를 의미하고, 블록 실행의 블록에 해당하는 outerContext는 홈 컨텍스트가 된다. 외부 컨텍스트 단계의 수는 중첩 수준만큼 존재한다.


아래 예를 살펴보자. 실행 시 ok를 누르면 대화창이 팝업된다.

| homeContext b1 |
homeContext := thisContext.
b1 := [| b2 |
    self assert: thisContext closure == b1.
    self assert: b1 outerContext == homeContext.
    self assert: b1 home = homeContext.
    b2 := [self assert: thisContext closure == b2.
        self assert: b2 outerContext closure outerContext == homeContext].
        self assert: b2 home = homeContext.
    b2 value].
b1 value

그림 14.5: pc 변수는 27을 보유하는데, 실행된 마지막 (바이트코드) 명령어가 메시지 전송 inspect였기 때문이다.


  • 먼저 블록 생성 이전의 컨텍스트 homeContext에서 설정한다. homeContext는 블록 b1과 b2의 홈 컨텍스트인데, 그 이유는 이 블록들이 그에 해당하는 실행 도중에 정의되기 때문이다.
  • ThisContext closure == b1 은 블록 b1의 실행 내부의 컨텍스트가 b1을 향하는 포인터를 갖고 있음을 보여준다. Assignment 이후부터 시작해 실행 도중에 b1가 정의되기 때문에 새로운 것은 없다. b1의 홈 컨텍스트는 그 외부 컨텍스트와 동일하다.
  • B2 실행 내부에서 현재 컨텍스트는 b2자체를 가리키는데 그것이 바로 클로저이기 때문이다. b2가 정의된 클로저의 외부 컨텍스트가 더 흥미로운데, 가령 b1는 homeContext 를 가리킨다. 마지막으로 b2의 홈 컨텍스트는 homeContext 이다. 마지막 포인트는 모든 중첩 블록이 구분된 외부 컨텍스트를 갖고 있으나 같은 홈 컨텍스트를 공유함을 보여준다.


메시지 실행

가상 머신은 메서드 또는 현재 실행 중인 (활성화된다는 용어가 사용되기도 한다) 블록마다 하나의 컨텍스트 객체로서 실행 상태를 나타낸다. Pharo에서 메서드 및 블록 실행은 MethodContext 인스턴스에 의해 표현된다. 본 장의 나머지 부분에서는 컨텍스트, 메서드 실행, 블록 클로저 평가를 다루겠다.

메시지 전송하기

수신자에게 메시지를 전송하기 위해 VM은 다음을 수행해야 한다.

  1. 수신자 객체의 헤더(header)를 이용해 수신자의 클래스를 찾는다.
  2. 클래스 메서드 dictionary에서 메서드를 검색한다. 메서드가 발견되지 않으면 각 슈퍼클래스에서 이 검색을 반복한다. 슈퍼클래스 사슬에서 어떤 클래스도 이 메시지를 이해할 수 없는 경우 VM은 수신자에게 doesNotUnderstnad: 메시지를 전송하여 오류가 해당 객체에 적절한 방식으로 처리되도록 한다.
  3. 적절한 메서드가 발견되면,
    (a) 메서드 헤더를 읽어서 메서드와 연관된 프리미티브를 확인한다.
    (b) 프리미티브가 있다면 그것을 실행한다.
    (c) 프리미티브가 성공적으로 완료되면 결과 객체를 메시지 전송자에게 리턴한다.
    (d) 프리미티브가 존재하지 않거나 실패할 경우 다음 단계를 계속한다.
  4. 새 컨텍스트를 생성한다. 프로그램 카운터, 스택 포인터, 홈 컨텍스트를 준비하고 메시지를 전송하는 컨텍스트의 스택으로부터 인자와 수신자를 새 스택으로 복사한다.
  5. 해당하는 새 컨텍스트를 활성화하고 새 메서드에서 명령어를 실행하기 시작한다.


메시지 전송 전의 실행 상태는 기억해야 하는데, 메시지 전송 이후 명령어는 메시지가 리턴할 때 실행되어야 하기 때문이다. 상태는 컨텍스트를 이용해 저장된다. 시스템에는 항상 다수의 컨텍스트가 존재한다. 현재 실행 상태를 나타내는 컨텍스트를 활성 컨텍스트라 부른다.


메시지 전송이 활성 컨텍스트에서 발생하면 활성 컨텍스트는 보류되고, 새 컨텍스트가 생성되어 활성화된다. 보류된 컨텍스트는 다시 활성화될 때까지 본래 컴파일된 메서드와 연관된 상태를 유지한다. 컨텍스트는 자신이 보류되었다는 사실을 기억해야 하며, 그래야만 결과가 리턴 시 보류된 컨텍스트가 재개될 수 있다. 보류된 컨텍스트를 새 컨텍스트의 전송자라 부른다. 그림 14.6은 컴파일된 메서드와 컨텍스트 간 관계를 표현한다. 메서드는 현재 실행된 메서드를 가리킨다. 프로그램 카운터는 컴파일된 메서드의 마지막 명령어를 가리키며, 전송자는 이전에 활성화된 컨텍스트를 가리킨다.

그림 14.6: 컨텍스트와 컴파일된 메서드의 관계.


구현의 개요

블록에 대한 임시 변수와 인자는 메서드에서와 같은 방식으로 처리된다. 인자는 스택 상에서 전달되고 임시 변수는 해당하는 컨텍스트에 보유된다. 그럼에도 불구하고 블록은 메서드보다 더 많은 변수로 접근이 가능한데, 블록은 enclosing 메서드로부터의 임시 변수와 인자로 참조할 수 있기 때문이다. 앞서 살펴보았듯 블록은 자유롭게 전달이 가능하며 언제든 활성화될 수 있다. 어떤 경우든 블록은 그것이 정의된 메서드로부터 변수를 수정 및 접근할 수 있다.


그림 14.7에 표시된 예를 살펴보자. ExampleReadInBlock 메서드의 블록에서 사용된 temp 변수는 non-local 변수이거나 원격 변수다. temp는 메서드 body에서 초기화 및 변경되고, 후에 블록에서 읽힌다. 변수의 실제 값은 블록 컨텍스트에 보관되지 않고 정의하는 메서드 컨텍스트, 즉 홈 컨텍스트에서 보관된다. 일반적인 구현에서 블록의 홈 컨텍스트는 그 closure를 통해 접근이 가능하다. 이러한 접근법은 메서드와 블록을 포함해 모든 객체가 일급(first-class) 객체일 경우에 효과가 좋다. 블록은 홈 컨텍스트 밖에서 평가되면서도 여전히 원격 변수를 참조할 수 있다. 따라서 모든 홈 컨텍스트는 메서드 활성보다 오래 생존할 수 있다.


구현. 앞에서 블록 컨텍스트와 관련해 언급한 접근법의 경우, 저수준 관점에서 볼 때 단점이 있다. 메서드와 블록 컨텍스트가 일반 객체인 경우 언젠가는 쓰레기 수집되어야 함을 의미한다. 다른 많은 객체를 호출하는 작은 메서드를 이용하는 일반적인 코딩 실습을 겸한다면 스몰토크 시스템은 수많은 컨텍스트를 생성할 수 있다.


메서드 컨텍스트를 처리하는 가장 효율적인 방식은 애초에 생성하지 않는 것이다. 가상 머신 수준에서 이는 실제 스택 프레임을 이용해 달성할 수 있겠다. 메서드 컨텍스트는 스택 프레임으로 쉽게 매핑이 가능하여, 메서드를 호출할 때마다 우리는 새 프레임을 생성하고, 메서드로부터 리턴할 때마다 현재 프레임을 삭제한다. 이 점에서 보면 스몰토크는 C와 별반 다르지 않다. 즉, 메서드로부터 리턴할 때마다 메서드 컨텍스트(스택 프레임)가 즉시 제거됨을 뜻한다. 따라서 고수준의 쓰레기 수집이 전혀 필요하지 않다. 그럼에도 불구하고 블록을 지원해야 한다면 스택의 이용은 훨씬 더 복잡해진다.

그림 14.7: 클로저 처음 이해하기.


앞서 언급했듯이 홈 컨텍스트로서 사용되는 메서드 컨텍스트는 그 활성 컨텍스트보다 오래 지속된다. 메서드 컨텍스트가 지금까지 설명한 바와 같이 작동한다면 스택 프레임이 제거되었는지 매번 홈 컨텍스트를 확인해야 할 것이다. 이는 성능에 큰 불이익을 가져온다. 그렇기 때문에 컨텍스트에 스택을 사용하기 위한 다음 단계는 메서드로부터 리턴할 때 메서드 컨텍스트가 안전하게 제거되도록 확보하는 일이다.


그림 14.8은 non-local 변수가 더 이상 홈 컨텍스트에 직접 보관되지 않고 할당된 힙(heap), 즉 구분된 원격 배열에 저장되는 모습을 보여준다.

그림 14.8: 메서드가 리턴할 때 계속해서 떠나도록(leave) VM이 원격 변수를 보관하는 방법.


요약

이번 장에서는 어휘적 클로저(lexical closures)라고도 불리는 블록의 사용법과 구현 방법을 학습했다. 블록을 정의하는 메서드가 리턴한다 하더라도 블록을 사용할 수 있음을 확인하였다. 블록은 고유의 변수를 비롯해 인스턴스 변수, 임시 변수, 정의하는 메서드의 인자에 해당하는 non local 변수로도 접근이 가능하다. 블록이 메서드를 어떻게 종료하고 전송자에게 값을 리턴하는지도 살펴보았다. 이러한 블록들은 non-local returning block이라 부르며, 오류를 피하기 위해 특별히 주의를 기울여야 하는데, 블록이 이미 리턴한 메서드를 종료할 수도 있기 때문이다. 마지막으로 컨텍스트가 무엇인지, 그들이 블록 생성과 실행에서 얼마나 중요한 역할을 하는지도 설명하였다. 뿐만 아니라 thisContext pseudo 변수가 무엇인지, 이를 사용해 컨텍스트의 실행에 관한 정보를 얻고 잠재적으로 변경할 수 있는 방법도 살펴보았다.


내용을 명료하게 정리해준 Eliot Miranda에게 감사의 말을 전한다.


Notes