DeepintoPharo:Chapter 13: Difference between revisions
Onionmixer (talk | contribs) (DIP 제 13 장 예외 처리하기 페이지 추가) |
Onionmixer (talk | contribs) (이미지 확장자 변경 및 이미지 추가) |
||
Line 212: | Line 212: | ||
스몰토크는 예외 처리 지원을 제공하지 않았기 때문에 앞 절에서 살펴본 작은 예제는 오류 코드를 이용해 아래와 같이 작성할 수 있겠다. | 스몰토크는 예외 처리 지원을 제공하지 않았기 때문에 앞 절에서 살펴본 작은 예제는 오류 코드를 이용해 아래와 같이 작성할 수 있겠다. | ||
[[image:DeepintoPharo_Image_13-1. | [[image:DeepintoPharo_Image_13-1.jpg|640px|그림 13.1: Pharo 예외 계층구조의 일부분.]] | ||
<syntaxhighlight lang="smalltalk"> | <syntaxhighlight lang="smalltalk"> | ||
Line 405: | Line 405: | ||
스몰토크에서는 모든 것이 객체이므로 메서드 컨텍스트도 객체일 것으로 예상할 것이다. 하지만 일부 스몰토크 구현은 끊임없는 객체의 생성을 피하기 위해 가상 머신의 native C 실행 스택을 사용한다. 최신 Pharo 가상 머신은 사실상 항상 모든(full) 스몰토크 객체를 사용하는데, 속도를 위해 각 메시지 전송마다 메서드 컨텍스트 객체를 새로 생성하는 대신 기존의 메서드 컨텍스트 객체를 재활용한다. | 스몰토크에서는 모든 것이 객체이므로 메서드 컨텍스트도 객체일 것으로 예상할 것이다. 하지만 일부 스몰토크 구현은 끊임없는 객체의 생성을 피하기 위해 가상 머신의 native C 실행 스택을 사용한다. 최신 Pharo 가상 머신은 사실상 항상 모든(full) 스몰토크 객체를 사용하는데, 속도를 위해 각 메시지 전송마다 메서드 컨텍스트 객체를 새로 생성하는 대신 기존의 메서드 컨텍스트 객체를 재활용한다. | ||
[[image:DeepintoPharo_Image_13-2. | [[image:DeepintoPharo_Image_13-2.jpg|640px|그림 13.2: Pharo 실행 스택.]] | ||
Line 869: | Line 869: | ||
convertCRtoLF: 메시지가 전송되었을 때 raiseWarning 설정이 true로 된 경우 팝업 창과 함께 알림이 표시되면서 프로그래머가 애플리케이션 실행을 재개할 수 있게 되는데, 이를 그림 13.3에 소개하고 있다 (Settings는 제 5장에서 상세히 설명한 바 있다). 물론 해당 메서드는 오래되어 사라졌기 때문에 현재 Pharo 배포판에선 찾아볼 수 없다. deprecated:on:in: 의 또 다른 전송자를 살펴보라. | convertCRtoLF: 메시지가 전송되었을 때 raiseWarning 설정이 true로 된 경우 팝업 창과 함께 알림이 표시되면서 프로그래머가 애플리케이션 실행을 재개할 수 있게 되는데, 이를 그림 13.3에 소개하고 있다 (Settings는 제 5장에서 상세히 설명한 바 있다). 물론 해당 메서드는 오래되어 사라졌기 때문에 현재 Pharo 배포판에선 찾아볼 수 없다. deprecated:on:in: 의 또 다른 전송자를 살펴보라. | ||
[[image:DeepintoPharo_Image_13-3. | [[image:DeepintoPharo_Image_13-3.jpg|그림 13.3: 오래되어 사라진 메시지 전송하기.]] | ||
Line 969: | Line 969: | ||
Pharo에서 Exception 클래스는 그림 13.4과 같이 10개의 직계 하위클래스를 갖는다. 그림에서 가장 먼저 눈에 띄는 점은 Exception 계층구조가 약간 엉망이라는 점이며, Pharo가 향상되면서 세부 내용이 변경될 것으로 예상할 수 있다. | Pharo에서 Exception 클래스는 그림 13.4과 같이 10개의 직계 하위클래스를 갖는다. 그림에서 가장 먼저 눈에 띄는 점은 Exception 계층구조가 약간 엉망이라는 점이며, Pharo가 향상되면서 세부 내용이 변경될 것으로 예상할 수 있다. | ||
[[image:DeepintoPharo_Image_13-4. | [[image:DeepintoPharo_Image_13-4.jpg|640px|그림 13.4: Pharo 예외 계층구조의 일부분.]] | ||
Line 1,095: | Line 1,095: | ||
</syntaxhighlight> | </syntaxhighlight> | ||
[[image:DeepintoPharo_Image_13-5. | [[image:DeepintoPharo_Image_13-5.jpg|320px|그림 13.5: 예외 클래스와 핸들러를 찾기 위해 메서드 컨텍스트 탐색하기.]] | ||
Line 1,329: | Line 1,329: | ||
[[image:DeepintoPharo_Image_13-6. | [[image:DeepintoPharo_Image_13-6.jpg|그림 13.6: 컨텍스트 스택]] | ||
Line 1,356: | Line 1,356: | ||
앞서 정의된 ensureWithOnDo 메서드의 실행을 보여주는 그림 13.6에서 무슨 일이 발생하는지 간략하게 살펴보자. | 앞서 정의된 ensureWithOnDo 메서드의 실행을 보여주는 그림 13.6에서 무슨 일이 발생하는지 간략하게 살펴보자. | ||
[[image:DeepintoPharo_Image_13-7. | [[image:DeepintoPharo_Image_13-7.jpg|그림 13.7: 그림 범례.]] | ||
* 왼쪽 코멘트: 메서드가 컨텍스트를 검색할 때 화살표는 메서드가 발견된 컨텍스트를 표시한다. | * 왼쪽 코멘트: 메서드가 컨텍스트를 검색할 때 화살표는 메서드가 발견된 컨텍스트를 표시한다. |
Revision as of 07:51, 31 March 2014
- 제 13 장 예외 처리하기
예외 처리하기
- Clément Bera 참여 (bera.clement@gmail.com)
모든 애플리케이션은 예외적인 상황을 처리해야 한다. 산술적 오류가 발생하기도 하고 (0으로 나눌 경우), 예기치 못한 상황이 발생하기도 하며 (파일을 찾을 수 없는 경우), 자원이 소진될 수도 있다 (네트워크가 다운되거나 디스크가 꽉 차는 경우 등). 예전에는 실패 연산이 특별한 오류 코드를 리턴하도록 만들어 이러한 문제를 해결했는데, 이는 클라이언트 코드가 각 연산의 리턴 값을 확인하고 오류를 처리하기 위해 특별한 액션을 취해야 함을 의미한다. 이러한 경우 불안정한 코드로 이어진다.
몇 가지 예제를 도움 삼아 이러한 가능성들을 모두 살펴보고, 예외와 예외 핸들러의 내재적 기법을 깊숙이 들여다보고자 한다.
서론
스몰토크를 포함해 현대 프로그래밍 언어들은 예외적 상황을 시그널링하고 처리하는 방식을 매우 간소화하는 예외 처리 전용의 메커니즘을 제공한다. 1996년에 ANSI 스몰토크 표준이 개발되기 이전에는 여러 개의 예외 처리 메커니즘이 존재했으며 대개 서로 호환이 되지 않았다. Pharo의 예외 처리는 ANSI 표준을 따르고, 몇 가지 embellishments를 이용하는데, 본 장에서는 사용자 관점으로부터 이를 제시하겠다.
예외 처리의 기본 개념은 클라이언트 코드가 오류 코드의 검사로 주요(main) 논리 흐름을 채우지 않고 대신 예외를 "잡아내도록" 예외 핸들러를 명시한다는 데에 있다. 무언가 잘못되면 오류 코드를 리턴하는 대신 예외적 상황을 감지한 메서드가 예외를 시그널링함으로써 주요 실행 흐름을 방해하는 것이다. 이는 두 가지 일을 해내는데, 예외가 발생하는 컨텍스트에 관한 필수 정보를 포착하고, 클라이언트가 작성한 예외 핸들러로 제어를 전달하여 어떻게 할 것인지 결정하도록 한다. "컨텍스트에 관한 필수 정보"는 Exception 객체에 저장되고, 발생할 수 있는 다양한 예외적 상황을 다루도록 Exception의 다양한 클래스가 명시된다.
Pharo의 예외 처리 메커니즘은 특별히 표현적이고 유연하여 광범위한 가능성을 가진다. 예외 핸들러는 무언가 잘못되더라도 특정 액션이 일어나도록 하거나 무언가 잘못되었을 때만 액션을 취하도록 보장하는 데에 사용할 수 있다. 스몰토크에서 여느 것과 마찬가지로 예외 또한 객체이며 다양한 메시지에 반응한다. 예외가 핸들러에 의해 포착되면 다수의 응답이 가능하다. 핸들러가 실행해야 할 대안적 액션을 명시할 수도 있고, 방해된 연산을 재개해줄 것을 예외 객체로 요청할 수도 있고, 연산을 재시도할 수 도 있으며, 다른 핸들러로 예외를 전달하기도 하고, 혹은 완전히 다른 예제를 재발생 시킬 수도 있다.
실행 보장하기
ensure: 메시지를 블록으로 전송하면 블록이 실패하더라도 (예: 예외를 발생시킨다) 인자 블록이 실행되도록 확보할 수 있다.
anyBlock ensure: ensuredBlock "ensuredBlock will run even if anyBlock fails"
사용자가 찍은 스크린샷에서 이미지 파일을 생성하는 아래 예제를 고려해보자.
| writer |
writer := GIFReadWriter on: (FileStream newFileNamed: 'Pharo.gif').
[ writer nextPutImage: (Form fromUser) ]
ensure: [ writer close ]
위의 코드는 파일로 작성 중이거나 Form fromUser에 오류가 발생하더라도 writer 파일 핸들이 닫히도록 보장한다.
좀 더 상세히 설명하자면 이렇다. GIFReadWriter 클래스의 nextPutImage: 메서드는 폼(예: 비트맵 이미지를 나타내는 Form 클래스의 인스턴스)을 GIF 이미지로 변환한다. 해당 메서드는 파일에서 열린 스트림으로 작성한다. nextPutImage: 메시지는 그것이 작성하는 스트림을 닫지 않으므로 작성하는 동안 문제가 발생하더라도 스트림이 닫히도록 보장해야 한다. 이는 작성을 수행하는 블록으로 ensure: 메시지를 전송하면 가능하다. nextPutImage: 가 실패할 경우 제어는 ensure: 로 전달된 블록으로 흐른다. 따라서 어떤 경우든 우리는 writer가 확실히 닫히도록 확보할 수 있다.
Cursor: 클래스를 살펴보면 ensure: 의 또 다른 사용 예를 볼 수 있다.
Cursor>>showWhile: aBlock
"While evaluating the argument, aBlock,
make the receiver be the cursor shape."
| oldcursor |
oldcursor := Sensor currentCursor.
self show.
^aBlock ensure: [ oldcursor show ]
aBlock이 예외를 시그널링하든 말든 인자[ oldcursor show]는 평가된다. ensure: 의 결과는 인자의 값이 아니라 수신자의 값임을 주목하라.
[ 1 ] ensure: [ 0 ] → 1 "not 0"
non-local returns 처리하기
ifCurtailed: 메시지는 주로 "정리(cleaning)" 액션에 사용된다. 이는 ensure: 와 비슷하지만, 수신자가 이례적으로 종료되더라도 인자 블록이 평가되도록 보장하는 ensure: 와 달리 ifCurtailed: 는 수신자가 실패하거나 리턴 할 때에만 인자 블록이 평가되도록 보장한다.
아래 예제에서 ifCurtailed: 의 수신자는 early return을 실행하기 때문에 다음 문은 절대 도달할 수 없다. 스몰토크에서는 이를 non-local return이라 부른다. 그럼에도 불구하고 인자 블록은 실행될 것이다.
[^ 10] ifCurtailed: [Transcript show: 'We see this'].
Transcript show: 'But not this'.
아래 예제에서는 수신자가 이례적으로 종료될 때에만 ifCurtailed: 에 대한 인자가 평가됨을 분명히 확인할 수 있다.
[Error signal] ifCurtailed: [Transcript show: 'Abandoned'; cr].
Transcript show: 'Proceeded'; cr.
transcript 를 열고 워크스페이스에 위의 코드를 평가하라. 디버거 창이 열리면 먼저 Proceed 를 선택하고 Abandon 을 선택하라. 수신자가 이례적으로 종료될 때에만 ifCurtailed: 로 인자가 평가됨을 명심하라. Debug 를 선택하면 어떤 일이 발생하는가?
ifCurtailed: 이 사용되는 예를 들 것인데, Transcript show: 의 텍스트는 상황을 설명한다.
[^ 10] ifCurtailed: [Transcript show: 'This is displayed'; cr]
[10] ifCurtailed: [Transcript show: 'This is not displayed'; cr]
[1 / 0] ifCurtailed: [Transcript show: 'This is displayed after selecting Abandon in the debugger'; cr]
Pharo에서 ifCurtailed: 와 ensure: 는 마커 프리미티브를 이용해 구현되지만 원칙적으로 ifCurtailed: 는 다음과 같이 ensure: 를 이용해 구현할 수도 있다.
ifCurtailed: curtailBlock
| result curtailed |
curtailed := true.
[ result := self value.
curtailed := false ] ensure: [ curtailed ifTrue: [ curtailBlock value ] ].
^ result
이와 비슷하게 ensure: 는 다음과 같이 ifCurtailed: 를 이용해 구현할 수도 있다.
ensure: ensureBlock
| result |
result := self ifCurtailed: ensureBlock.
"If we reach this point, then the receiver has not been curtailed,
so ensureBlock still needs to be evaluated"
ensureBlock value.
^ result
ensure: 과 ifCurtailed: 모두 그 중요한 "cleanup" 코드가 실행되도록 확보하는 데 매우 유용하지만 그 둘 만으로 모든 예외적 상황을 처리하기엔 역부족이다. 이제 예외 처리를 위한 좀 더 일반적인 메커니즘을 살펴보자.
예외 핸들러
일반적인 메커니즘은 on:do: 메시지에 의해 제공되는데, 그 모습은 아래와 같다.
aBlock on: exceptionClass do: handlerAction
aBlock은 이상한 상황을 감지하고 예외를 시그널링하는 코드로서, 보호 블록(protected block)이라 불린다. handlerAction은 예외가 시그널링될 경우 평가되는 블록이며, 예외 핸들러라고 불린다. exceptionClass는 예외의 클래스를 정의하고, handlerAction는 예외를 처리하도록 요청 받을 것이다.
on:do: 메시지는 수신자의 (보호 블록) 값을 리턴하고 오류가 발생 시 아래 표현식과 같이 handlerAction 블록의 값을 리턴한다.
[1+2] on: ZeroDivide do: [:exception | 33]
→ 3
[1/0] on: ZeroDivide do: [:exception | 33]
→ 33
[1+2. 1+ 'kjhjkhjk'] on: ZeroDivide do: [:exception | 33]
→ raise another Error
이 메커니즘의 미는 어떤 가능한 오류와도 상관 없이 보호 블록을 손쉬운 방식으로 작성할 수 있다는 데에 있다. 하나의 예외 핸들러는 잘못될 가능성이 있는 상황을 처리할 책임을 지닌다.
아래 예제에서 우리는 한 파일 상의 내용을 다른 파일로 복사하길 원한다. 파일과 관련해 여러 가지 일이 잘못될 수 있지만 예외 처리를 이용하면 간단한 행의 메서드를 작성하여 전체 transaction에 대해 하나의 예외 핸들러를 정의할 수 있다.
| source destination fromStream toStream |
source := 'log.txt'.
destination := 'log-backup.txt'.
[ fromStream := FileStream oldFileNamed: (FileSystem workingDirectory / source).
[ toStream := FileStream newFileNamed: (FileSystem workingDirectory / destination).
[ toStream nextPutAll: fromStream contents ]
ensure: [ toStream close ] ]
ensure: [ fromStream close ] ]
on: FileStreamException
do: [ :ex | UIManager default inform: 'Copy failed -- ', ex description ].
FileStreams와 관련해 예외가 발생하면 예외 객체를 그 인자로 가진 핸들러 블록(do: 다음에 오는 블록)이 실행된다. 핸들러 코드가 사용자에게 복사가 실패했음을 알리고, 오류에 관한 세부 내용을 제공하는 업무를 예외 객체 ex에게 위임한다. 두 번의 중첩된 ensure: 의 사용은 예외의 발생 여부와 상관없이 두 개의 파일 스트림이 닫히도록 보장한다.
on:do: 메시지의 수신자인 블록이 예외 핸들러의 범위를 정의한다는 사실을 이해하는 것이 중요하다. 해당 핸들러는 수신자가 (예: 보호 블록) 완료되지 않았을 경우에만 사용될 것이다. 우선 완료되면 예외 핸들러는 사용되지 않을 것이다. 뿐만 아니라 핸들러는 on:do: 에 대한 첫 번째 인자로서 명시된 예외 유형과 독자적으로 연관된다. 따라서 앞의 예제에서는 FileStreamException(또는 좀 더 구체적인 변형체)만 처리될 수 있다.
버그 해결. 아래 코드를 연구하고 무엇이 잘못되었는지 확인하라.
| source destination fromStream toStream |
source := 'log.txt'.
destination := 'log-backup.txt'.
[ fromStream := FileStream oldFileNamed: (FileSystem workingDirectory / source).
toStream := FileStream newFileNamed: (FileSystem workingDirectory / destination).
toStream nextPutAll: fromStream contents ]
on: FileStreamException
do: [ :ex | UIManager default inform: 'Copy failed -- ', ex description ].
fromStream ifNotNil: [fromStream close].
toStream ifNotNil: [toStream close].
FileStreamException 를 제외한 예외가 발생할 경우 파일이 적절하게 닫히지 않는다.
오류 코드 — 제발 삼가하라!
예외를 사용하는 방법 외에 예기치 않은 결과를 생성하지 못하는 메서드를 처리하는 방법 중 바람직하지 못한 방법을 들자면, 가능한 리턴 값으로서 명시적 오류 코드를 소개하는 것을 들 수 있겠다. 사실상 C와 같은 언어에서 코드는 그러한 오류 코드에 대한 검사(check)로 인해 지저분해지고, 메인 애플리케이션 로직을 모호하게 만들기도 한다. 오류 코드는 그 진화에도 불구하고 취약하다고 볼 수 있는데, 새 오류 코드가 추가되면 모든 클라이언트는 새 코드를 고려하도록 적응해야 하기 때문이다. 오류 코드 대신 예외를 사용할 경우 프로그래머는 각 리턴 값을 명시적으로 검사하는 업무로부터 벗어나고, 프로그램 로직은 깔끔한 모습 그대로 유지된다. 뿐만 아니라 예외는 클래스에 해당하기 때문에 새로운 예외적 상황이 발견되면 서브클래싱될 수도 있다. 따라서 기존 클라이언트는 새 클라이언트보다 덜 특정적인 예외를 제공하긴 하지만 여전히 작동할 것이다.
스몰토크는 예외 처리 지원을 제공하지 않았기 때문에 앞 절에서 살펴본 작은 예제는 오류 코드를 이용해 아래와 같이 작성할 수 있겠다.
"Pseudo-code -- luckily Smalltalk does not work like this. Without the
benefit of exception handling we must check error codes for each operation."
source := 'log.txt'.
destination := 'log-backup.txt'.
success := 1. "define two constants, our error codes"
failure := 0.
fromStream := FileStream oldFileNamed: (FileSystem workingDirectory / source).
fromStream ifNil: [
UIManager default inform: 'Copy failed -- could not open', source.
^ failure "terminate this block with error code" ].
toStream := FileStream newFileNamed: (FileSystem workingDirectory / destination).
toStream ifNil: [
fromStream close.
UIManager default inform: 'Copy failed -- could not open', destination.
^ failure ].
contents := fromStream contents.
contents ifNil: [
fromStream close.
toStream close.
UIManager default inform: 'Copy failed -- source file has no contents'.
^ failure ].
result := toStream nextPutAll: contents.
result ifFalse: [
fromStream close.
toStream close.
UIManager default inform: 'Copy failed -- could not write to ', destination.
^ failure ].
fromStream close.
toStream close.
^ success.
엉망진창이다! 예외 처리가 없이는 다음 연산으로 넘어가기 전에 각 연산의 결과를 명시적으로 확인해야 한다. 무언가 잘못되는 지점마다 오류 코드를 확인해야 할 뿐만 아니라 그 시점까지 실행된 모든 연산을 정리(cleanup)하고 나머지 코드 부분을 취소할 준비도 되어 있어야 한다.
어떤 예외를 처리할 것인지 명시하기
스몰토크에서는 예외도 물론 객체다. Pharo에서 예외는 예외 클래스의 인스턴스로서, 예외 클래스의 계층구조에 해당한다. 예를 들어, FileDoesNotExistException, FileExistsException, CannotDeleteFileException 예외들은 FileStreamException 의 특별한 유형으로, 그림 13.1에서와 같이 FileStreamException의 서브클래스로서 표현된다. "특수화(specialization)"는 예외 핸들러를 다소 일반적인 예외적 상황과 연관하도록 해준다. 따라서 우리가 원하는 세분성의 수준에 따라 여러 표현식을 작성할 수 있다.
[ ... ] on: Error do: [ ... ] or
[ ... ] on: FileStreamException do: [ ... ] or
[ ... ] on: FileDoesNotExistException do: [ ... ]
FileStreamException 클래스는 그것이 설명하는 구체적인 이상한 상황을 특징짓기 위해 Exception 클래스로 정보를 추가한다. 특히 FileStreamException은 fileName 인스턴스 변수를 정의하는데, 이것은 예외를 시그널링한 파일명을 포함한다. 예외 클래스 계층구조의 루트는 Object의 직계 서브클래스인 Exception이다.
예외 처리에는 두 개의 메시지가 수반되는데, 앞에서 살펴봤듯이 on:do: 는 예외 핸들러를 설정하기 위해 블록으로 전송되고, signal은 예외가 발생하였음을 시그널링하기 위해 Exception의 서브클래스로 전송된다.
예외의 집합 포착하기
지금까지는 예외의 단일 클래스를 포착하는 데에만 on:do: 를 사용해왔다. 시그널링된 예외가 명시된 예외 클래스의 하위 인스턴스인 경우에만 핸들러가 호출될 것이다. 하지만 다수의 exception 클래스를 포착하길 원하는 상황을 상상해보자. 이는 간단한데, 아래 예제와 같이 콤마로 구분된 클래스 리스트를 명시하면 된다.
result := [ Warning signal . 1/0 ]
on: Warning, ZeroDivide
do: [:ex | ex resume: 1 ].
result → 1
어떻게 작동하는지가 궁금하다면 Exception class>> 의 구현을 살펴보라.
Exception class>> anotherException
"Create an exception set."
^ExceptionSet new add: self; add: anotherException; yourself
나머지 마법은 ExceptionSet 클래스에서 발생하는데, 그 구현은 놀라울 정도로 간단하다.
Object subclass: #ExceptionSet
instanceVariableNames: 'exceptions'
classVariableNames: ''
poolDictionaries: ''
category: 'Exceptions-Kernel'
ExceptionSet>>initialize
super initialize.
exceptions := OrderedCollection new
ExceptionSet>>, anException
self add: anException.
^self
ExceptionSet>>add: anException
exceptions add: anException
ExceptionSet>>handles: anException
exceptions do: [:ex | (ex handles: anException) ifTrue: [^true]].
^false
handles: 메시지는 단일 예외에서 정의되고, 수신자가 예외를 처리하는지 여부를 리턴한다.
예외 시그널링하기
예외를 시그널링하기 위해서는[1] 예외 클래스의 인스턴스를 생성하여 그곳으로 텍스트 설명과 함께 signal: 메시지 signal 또를 전송하기만 하면 된다. Exception 클래스는 예외를 생성하여 시그널링하는 편의(convenience) 메서드 signal을 제공한다. ZeroDivide 예외를 간편하게 시그널링하는 방법으로 두 가지가 있다.
ZeroDivide new signal.
ZeroDivide signal. "class-side convenience method does the same as above"
예외를 시그널링하기 위해 예외를 생성하는 일을 예외 클래스가 책임지지 않고 직접 생성해야 하는 이유가 궁금할지도 모르겠다. 인스턴스의 생성이 중요한 이유는 예외가 시그널링된 컨텍스트에 관한 정보를 캡슐화하기 때문이다. 따라서 각각이 다른 예외의 컨텍스트를 설명하는 예외 인스턴스를 많이 가질 수 있다.
예외가 시그널링되면 예외 처리 메커니즘은 실행 스택에서 시그널링된 예외의 클래스와 연관된 예외 핸들러를 검색한다. 핸들러를 마주치면 (예: 스택 상에 on:do: 메시지가 있는 경우), implementation은 exceptionClass가 시그널링된 예외의 슈퍼클래스인지를 확인하고, 예외를 유일한 인자로 하여 handlerAction를 실행한다. 핸들러가 예외 객체를 사용할 수 있는 방법을 간략하게 살펴볼 것이다.
예외를 시그널링할 때는 아래 코드에서 볼 수 있듯이 방금 마주친 상황에 특정적인 정보를 제공하는 것이 가능하다. 예를 들어, 열어야 할 파일이 존재하지 않는다면 존재하지 않는 파일명이 예외 객체에 기록될 수 있다.
StandardFileStream class>>oldFileNamed: fileName
"Open an existing file with the given name for reading and writing. If the name has no
directory part, then default directory will be assumed. If the file does not exist, an
exception will be signaled. If the file exists, its prior contents may be modified or
replaced, but the file will not be truncated on close."
| fullName |
fullName := self fullName: fileName.
^(self isAFileNamed: fullName)
ifTrue: [self new open: fullName forWrite: true]
ifFalse: ["File does not exist..."
(FileDoesNotExistException new fileName: fullName) signal]
예외 핸들러는 이상한 상황에서 회복하기 위해 이 정보를 이용할 수도 있다. 예외 핸들러 [:exㅣ...]에서 ex는 FileDoesNotExistException의 인스턴스이거나 그 서브클래스 중 하나의 인스턴스가 될 것이다. 이는 fileName을 전송하여 missing 파일의 파일명을 질의하는 예외이다.
| result |
result := [(StandardFileStream oldFileNamed: 'error42.log') contentsOfEntireFile]
on: FileDoesNotExistException
do: [:ex | ex fileName , ' not available'].
Transcript show: result; cr
모든 예외는 예외적 상황을 명확하고 포괄적인 방식으로 보고하기 위해 개발 툴이 사용하는 기본 설명을 갖고 있다. 설명을 이용 가능하게 만들기 위해 모든 예외 객체들은 description 메시지에 응답한다. 그리고 messageText: aDescription 메시지를 전송하거나, signal: aDescription 예외를 시그널링하면 기본 설명을 변경할 수 있다.
또 다른 시그널링 예제는 스몰토크의 반영적 기능의 중심인 doesNotUnderstand: 메커니즘에서 발생한다. 객체가 이해하지 못하는 메시지가 객체로 전송될 때마다 가상 머신은 (결국) 문제가 되는 메시지를 표현하는 인자와 함께 doesNotUnderstand: 메시지를 전송할 것이다. Object 클래스에 정의된 doesNotUnderstand: 의 기본 구현은 MessageNotUnderstood 예외를 시그널링하여 실행 중 그 시점에서 디버거가 열리도록 한다.
doesNotUnderstand: 메서드는 예외 특정적인 정보, 즉 이해하지 못한 메시지나 수신자 등의 정보를 예외로 저장하여 디버거에서 이용 가능하게 만드는 방법을 묘사한다.
Object>>doesNotUnderstand: aMessage
"Handle the fact that there was an attempt to send the given message to the receiver
but the receiver does not understand this message (typically sent from the machine
when a message is sent to the receiver and no method is defined for that selector).
"
MessageNotUnderstood new
message: aMessage;
receiver: self;
signal.
^ aMessage sentTo: self.
이것으로 예외가 어떻게 사용되는지에 대한 설명은 끝났다. 나머지 부분은 예외가 어떻게 구현되는지를 논하고, 자신의 예외를 정의할 경우에만 관련된 세부 내용을 추가하겠다.
핸들러 찾기
이제 예외가 시그널링되면 어떻게 예외 핸들러를 검색하고 실행 스택으로부터 인출하는(fetch)지를 살펴볼 것이다. 하지만 이를 알아보기 전에 프로그램의 제어 흐름이 가상 머신에서 내부적으로 어떻게 표현되는지를 이해할 필요가 있겠다.
프로그램의 실행 시 각 포인트마다 프로그램의 실행 스택은 활성 컨텍스트(activation context)의 리스트로서 표현된다. 각 활성 컨텍스트는 메서드 호출을 나타내고, 그 실행에 필요한 모든 정보, 즉 수신자, 인자, 로컬 변수를 포함한다. 뿐만 아니라 생성을 트리거하는 컨텍스트에 대한 참조도 포함하는데, 가령 해당하는 컨텍스트를 생성한 메시지를 전송한 메서드 실행에 연관된 활성 컨텍스트를 예로 들 수 있겠다. Pharo에서는 MethodContext(슈퍼클래스가 ContextPart에 해당하는)가 이러한 정보를 모델링한다. 활성 컨텍스트들 간 참조는 그들을 사슬로 연결하는데, 이러한 활성 컨텍스트의 사슬이 바로 스몰토크의 실행 스택이다.
존재하지 않는 파일로부터 doIt 을 통해 FileStream 열기를 시도한다고 가정해보자. FileDoesNotExistException 이 시그널링되고, 실행 스택은 그림 13.2와 같이 doIt, oldFileNamed, signal에 대한 MethodContexts를 포함할 것이다.
스몰토크에서는 모든 것이 객체이므로 메서드 컨텍스트도 객체일 것으로 예상할 것이다. 하지만 일부 스몰토크 구현은 끊임없는 객체의 생성을 피하기 위해 가상 머신의 native C 실행 스택을 사용한다. 최신 Pharo 가상 머신은 사실상 항상 모든(full) 스몰토크 객체를 사용하는데, 속도를 위해 각 메시지 전송마다 메서드 컨텍스트 객체를 새로 생성하는 대신 기존의 메서드 컨텍스트 객체를 재활용한다.
aBlock on: ExceptionClass do: actionHandler 를 전송할 때는 aBlock 라는 보호 블록의 활성 컨텍스트에 대해 주어진 예외 클래스(ExceptionClass)와 예외 핸들러(actionHandler)를 연관시키려는 의도를 갖고 있다. 이러한 정보는 적절한 클래스의 예외가 시그널링될 때마다 actionHandler 를 식별하고 실행하는 데에 사용되는데, actionHandler 는 꼭대기부터 (가장 최근의 메시지 전송) 시작해 on:do: 메시지를 전송한 컨텍스트까지 내려오면서 순회하여 찾을 수 있다.
스택에서 어떤 예외 핸들러도 발견되지 않을 경우 ContextPart>>handleSignal: 또는 UndefinedObject>>handleSignal: 에 의해 defaultAction 메시지가 전송된다. 후자는 스택의 맨 아래에 연관되며, 아래와 같이 정의된다.
UndefinedObject>>handleSignal: exception
"When no more handler (on:do:) context is left in the sender chain, this gets called.
Return from signal with default action."
^ exception resumeUnchecked: exception defaultAction
handleSignal: 메시지는 Exception>>signal 에 의해 전송된다.
예외 E가 시그널링되면 시스템은 아래와 같이 스택을 하향으로 검색함으로써 해당하는 예외 핸들러를 식별하고 인출한다.
- 핸들러에 대한 현재 활성 컨텍스트를 살펴보고 핸들러가 canHandleSignal: E 인지 테스트하라.
- 스택이 비어 있지 않은데 어떤 핸들러도 발견되지 않은 경우 스택의 하단에서 단계 1로 돌아가라.
- 어떤 핸들러도 발견되지 않고 스택도 비어 있는 경우 E로 defaultAction을 전송하라. Error 클래스 내의 기본 구현은 디버거를 여는 것이다.
- 핸들러가 발견되는 경우 핸들러로 value: E 를 전송하라.
중첩 예외. 예외 핸들러는 그들 고유의 범위 밖에 있다. 즉, 예외가 예외 핸들러 내부로부터 시그널링될 경우를 중첩 예외라고 부르는데, 이런 일이 발생하면 중첩 예외를 포착하도록 별도의 핸들러를 설정해야 한다.
on:do: 메시지가 다른 메시지의 수신자에 해당하는 예제를 들어보되, 이 다른 메시지는 on:do: 메시지의 핸들러에 의해 시그널링되는 오류를 포착할 것이다 (on:do: 의 결과는 보호 블록이거나 핸들러 액션 블록 값임을 기억하라).
result := [[ Error signal: 'error 1' ]
on: Exception
do: [ Error signal: 'error 2' ]]
on: Exception
do: [:ex | ex description ].
result → 'Error: error 2'
두 번째 핸들러가 없다면 중첩 예외는 포착되지 않을 것이며, 디버거가 호출될 것이다.
위를 방법을 첫 번째 메시지 내에 두 번째 핸들러를 명시하는 대안책이 있다.
result := [ Error signal: 'error 1' ]
on: Exception
do: [[ Error signal: 'error 2' ]
on: Exception
do: [:ex | ex description ]].
result → 'Error: error 2'
미묘한 점이니 시도해보고 연구하길 바란다.
예외 처리하기
예외가 시그널링되면 핸들러는 예외를 처리하는 것과 관련해 여러 선택권을 갖는데, 무엇보다도 아래를 실행할 수 있다.
- (I) 단순히 대안적 결과를 명시함으로써 보호 블록의 실행을 abandon(포기)한다. 이는 프로토콜의 일부에 해당하지만 return 과 비슷하기 때문에 사용되지 않는다.
- (II) 예외 객체로 return: aValue 를 전송함으로써 보호 블록에 대한 대안적 결과를 return(반환)한다.
- (III) retry 를 전송함으로써 보호 블록을 재시도하거나 retryUsing: 을 전송하여 다른 블록을 시도한다.
- (IV) 실패 지점에서 resume 또는 resume: 을 전송함으로서 보호 블록을 resume(재개)한다.
- (V) pass 를 전송함으로써 잡은 예외를 enclosing 핸들러로 pass(전달)한다.
- (VI) 예외로 resignalAs: 를 전송함으로써 다른 예외를 resignal(재시그널링)한다.
앞의 세 가지 가능성을 간략하게 살펴본 후 나머지를 자세히 살펴보겠다.
보호 블록 포기하기
첫 번째 가능성은 아래와 같이 보호 블록의 실행을 단념하는 방법이다.
answer := [ |result|
result := 6*7.
Error signal.
result "This part is never evaluated" ]
on: Error
do: [ :ex | 3 + 4 ].
answer → 7
핸들러는 오류가 시그널링되는 지점부터 인계 받고, 원본 블록 내에서 그 다음에 따라오는 코드는 평가되지 않는다.
return: 를 이용해 값 리턴하기
블록은 블록의 보호 여부와 상관없이 블록에서 마지막 문의 값을 리턴한다. 하지만 핸들러 블록에 의해 결과가 리턴되어야 하는 상황이 종종 있다. 예외로 전송된 return: aValue 메시지는 보호 블록의 값으로서 aValue 를 리턴하는 효과를 갖는다.
result := [Error signal]
on: Error
do: [ :ex | ex return: 3 + 4 ].
result → 7
ANSI 표준은 값을 리턴할 때 do: [:ex | 100 ] 를 사용할 때와 do: [:ex | ex return: 100]를 사용할 때 차이점에 대해 불분명하다. Pharo에선 두 표현식이 동일하긴 하지만 목적을 좀 더 알려주는 return: 을 사용할 것을 권한다.
return: 의 변형체로 return이라는 메시지가 있는데, 이는 nil을 리턴한다.
어떤 경우든 제어(control)는 보호 블록으로 리턴하진 않겠지만 enclosing 컨텍스트까지는 전달될 것임을 기억하라.
6*([Error signal] on: Error do: [ :ex | ex return: 3 + 4 ]) → 42
retry와 retryUsing: 을 이용해 계산 재시도하기
때로는 예외를 야기한 상황을 변경하여 보호 블록을 재시도하길 원하는 경우가 있다. 이는 예외 객체로 retry 또는 retryUsing: 을 전송하면 가능하다. 예외를 야기한 조건은 보호 블록을 재시도하기 전에 변경하는 것이 중요한데, 이를 어길 경우 무한 루프가 발생할 것이다.
[Error signal] on: Error do: [:ex | ex retry] "will loop endlessly"
조금 더 나은 예제를 소개하겠다. 보호 블록은 theMeaningOfLife가 적절히 초기화되어 있는 수정된(modified) 환경에서 재평가된다.
result := [ theMeaningOfLife*7 ] "error -- theMeaningOfLife is nil"
on: Error
do: [:ex | theMeaningOfLife := 6. ex retry ].
result → 42
retryUsing: aNewBlock 메시지는 보호 블록을 aNewBlock으로 대체할 수 있게 해준다. 이러한 새 블록은 원본 블록과 동일한 핸들러를 이용해 실행 및 보호된다.
x := 0.
result := [ x/x ] "fails for x=0"
on: Error
do: [:ex |
x := x + 1.
ex retryUsing: [1/((x-1)*(x-2))] "fails for x=1 and x=2"
].
result → (1/2) "succeeds when x=3"
아래 코드는 무한 루프를 야기하는 반면,
[1 / 0] on: ArithmeticError do: [:ex | ex retryUsing: [ 1 / 0 ]]
아래 코드는 Error 를 시그널링할 것이다.
[1 / 0] on: ArithmeticError do: [:ex | ex retryUsing: [ Error signal ]]
또 다른 예제로 앞에서 살펴본 파일 처리 코드, 즉 파일이 발견되지 않을 경우 Transcript 상에 메시지를 출력하는 예제를 상기시켜보자. 대신 아래와 같이 파일의 입력을 요청할 수 있다.
[(StandardFileStream oldFileNamed: 'error42.log') contentsOfEntireFile]
on: FileDoesNotExistException
do: [:ex | ex retryUsing: [FileList modalFileSelector contentsOfEntireFile] ]
실행 재개하기
예외를 시그널링하는 메서드 isResumable은 시그널링 직후에 재개가 가능하다. 따라서 예외 핸들러는 일부 액션을 실행한 후 실행 흐름을 재개할 수 있다. 이러한 행위는 핸들러 내 예외로 resume: 을 전송함으로써 얻는다. 인자는 예외를 시그널링한 표현식 대신에 사용될 값이다. 아래 예제에서는 시그널링한 다음 Tests-Exceptions 범주에서 정의되는 MyResumableTestError를 포착할 것이다.
result := [ | log |
log := OrderedCollection new.
log addLast: 1.
log addLast: MyResumableTestError signal.
log addLast: 2.
log addLast: MyResumableTestError signal.
log addLast: 3.
log ]
on: MyResumableTestError
do: [ :ex | ex resume: 0 ].
result → an OrderedCollection(1 0 2 0 3)
여기서 우리는 MyResumableTestError signal의 값이 resume: 메시지에 대한 인자 값임을 분명히 확인할 수 있다.
resume 메시지는 resume: nil 과 같다.
예외를 재개할 경우 이점은 아래와 같이 로딩되는 기능을 통해 설명된다. 패키지를 설치 시 경고가 시그널링되기도 한다. 경고는 위험한 오류로 간주되어선 안 되기 때문에 경고를 단순히 무시하고 설치를 계속하면 된다. PackageInstaller 클래스는 존재하지 않지만 가능한 구현을 요약하자면 다음과 같다.
PackageInstaller>>installQuietly: packageNameCollection
....
[ self install ] on: Warning do: [ :ex | ex resume ].
재개가 유용한 또 다른 상황으로, 사용자에게 어떤 일을 하도록 요청할 때를 들 수 있겠다. 예를 들어, 아래 메서드를 이용해 ResumableLoader 클래스를 정의한다고 가정해보자.
ResumableLoader>>readOptionsFrom: aStream
| option |
[aStream atEnd]
whileFalse: [option := self parseOption: aStream.
"nil if invalid"
option isNil
ifTrue: [InvalidOption signal]
ifFalse: [self addOption: option]].
유효하지 않은 옵션을 마주치면 InvalidOption 예외를 시그널링한다. readOptionsFrom: 을 전송하는 컨텍스트는 적절한 핸들러를 준비할 수 있다.
ResumableLoader>>readConfiguration
| stream |
stream := self optionStream.
[self readOptionsFrom: stream]
on: InvalidOption
do: [:ex | (UIManager default confirm: 'Invalid option line. Continue loading?')
ifTrue: [ex resume]
ifFalse: [ex return]].
stream close
스트림을 확실히 닫히도록 확보하기 위해서는 ensure: 호출을 이용해 stream close를 보호해야 한다.
사용자 입력에 따라 readConfiguration 내 핸들러는 nil(nil을 리턴)을 리턴하거나, 예외를 resume(재개)하여 readOptionsFrom: 내에서 리턴해야 할 signal 메시지 전송과 계속해야 할 옵션 스트림의 파싱을 야기한다.
InvalidOption은 재개 가능해야 함을 주목하고, 그것만으로도 Exception의 하위클래스로서 정의하기엔 충분하다.
사용 방법은 resume: 의 전송자를 살펴보도록 한다.
예외 전달하기
예외의 전달과 같은 예외 처리의 다른 방법들을 설명하기 위해 perform: 메서드의 일반화(generalization)를 구현하는 방법을 살펴보도록 하겠다. 객체로 perform: aSymbol 을 전송하면 이는 aSymbol 이라는 메시지를 해당 객체로 전송되도록 야기할 것이다.
5 perform: #factorial → 120 "same as: 5 factorial"
해당 메서드의 여러 변형체가 존재하는데, 아래를 예로 들 수 있겠다.
1 perform: #+ withArguments: #(2) → 3 "same as: 1 + 2"
perform: 과 닮은 메서드들은 동적으로 인터페이스로 접근하기에 매우 유용한데, 전송되는 메시지들을 런타임 시 결정할 수 있기 때문이다. 누락된 메시지가 하나 있는데 이는 주어진 수신자로 단항 메시지의 cascade를 전송하는 메시지다. 단순하면서 간단한 구현을 소개하겠다.
Object>>performAll: selectorCollection
selectorCollection do: [:each | self perform: each] "aborts on first error"
해당 메서드는 아래와 같이 사용 가능하다.
Morph new performAll: #( #activate #beTransparent #beUnsticky)
하지만 문제가 하나 있다. 컬렉션 내에 객체가 이해하지 못하는 선택자가 존재할 수 있다는 것이다 (예: #activate). 그러한 선택자는 무시하고 나머지 메시지의 전송을 계속하겠다. 아래 구현이 적당해 보인다.
Object>>performAll: selectorCollection
selectorCollection do: [:each |
[self perform: each]
on: MessageNotUnderstood
do: [:ex | ex return]] "also ignores internal errors"
좀 더 자세히 살펴보니 또 다른 문제가 있다. 이 핸들러는 본래 수신자가 이해하지 못하는 메시지를 포착하고 무시할 뿐만 아니라 이해된 메시지에 대해, 메서드 내에서 전송되었으나 이해되지 않은 메시지들도 포착하고 무시할 것이란 점이다. 이는 그러한 메서드에 프로그래밍 오류를 숨길 것인데, 우리 의도와는 다르다. 현재 선택자의 실행을 시도하여 야기되었는지 확인하기 위해 예외를 분석하는 핸들러가 필요하다. 올바른 구현은 다음과 같다.
메서드 13.1: Object>>performAll:
Object>>performAll: selectorCollection
selectorCollection do: [:each |
[self perform: each]
on: MessageNotUnderstood
do: [:ex | (ex receiver == self and: [ex message selector == each])
ifTrue: [ex return]
ifFalse: [ex pass]]] "pass internal errors on"
MessageNotUnderstood 오류가 만일 우리가 실행 중인 메시지 리스트에 속하지 않는 경우, 이는 그러한 오류를 주위 컨텍스트로 전달하는 효과를 일으킨다. pass 메시지는 실행 스택에서 다음으로 적용 가능한 핸들러로 예외를 전달할 것이다.
스택에 다음 핸들러가 존재하지 않을 경우 defaultAction 메시지가 예외 인스턴스로 전송된다. pass 액션은 조금도 전송자 사슬을 수정하지 않지만 제어(control)가 전달되는 핸들러는 전송자 사슬을 수정할지도 모른다. 이번 절에서 논한 다른 메시지들과 마찬가지로 pass 또한 특별한 메시지인데, 절대로 전송자에게 리턴하지 않기 때문이다.
이번 절의 목표는 예외의 강점을 설명하는 데에 있다. 예외를 이용해 거의 모든 일을 할 수 있는 반면 그로 야기되는 코드를 이해하기가 항상 쉬운 것은 아니란 사실이 명백해진다. 예외를 사용하지 않고 더 단순한 방식으로 동일한 효과를 얻는 방법도 있는데, performAll: 을 더 잘 구현하는 방법은 291 페이지의 메서드 13.2를 참고하라.
예외 재전송하기
performAll: 예제에서 수신자가 이해하지 못한 선택자를 더 이상 무시하지 말고 그러한 선택자의 발생을 오류로 고려하길 원한다고 가정해보자. 하지만 그러한 선택자는 일반적인 MessageNotUnderstood가 아니라 애플리케이션 특정적 예외, 즉 InvalidAction으로서 시그널링되길 원한다. 다시 말하자면, 우리는 시그널링된 예외를 다른 예외로서 "재시그널링"할 수 있는 능력을 원한다고 볼 수 있겠다.
언뜻 보면 핸들러 블록에서 새 예외를 시그널링하는 것으로 단순해 보일지도 모른다. performAll: 의 구현에서 핸들러 블록은 다음과 같을 것이다.
[:ex | (ex receiver == self and: [ex message selector == each])
ifTrue: [InvalidAction signal] "signals from the wrong context"
ifFalse: [ex pass]]
하지만 좀 더 가까이 살펴보면 미묘한 문제가 존재한다. 본래 의도는 MessageNotUnderstand의 발생을 InvalidAction으로 대체하는 것이었다. 이러한 대체는 프로그램 내에서 원래의 MessageNotUnderstood 예외와 같은 장소에서 InvalidAction이 시그널링되는 효과를 보여야 한다. 그런데 위의 해결책은 InvalidAction을 다른 장소에서 시그널링한다. 위치의 차이는 적용 가능한 핸들러의 차이로 이어질 것이다.
이러한 문제를 해결하기 위해 예외를 재시그널링하는 것은 시스템이 처리하는 특수 액션이다. 이러한 목적으로 시스템은 resignalAs: 메시지를 제공한다. performAll: 예제에서 핸들러 블록의 올바른 구현은 다음과 같을 것이다.
[:ex | (ex receiver == self and: [ex message selector == each])
ifTrue: [ex resignalAs: InvalidAction] "resignals from original context"
ifFalse: [ex pass]]
outer와 pass 비교하기
ANSI 프로토콜은 outer 행위를 명시하기도 한다. outer 메서드는 pass와 매우 유사하다. 예외로 outer를 전송해도 enclosing 핸들러 액션을 평가한다. 유일한 차이는 outer 핸들러는 예외를 재개한 후 애초에 예외가 시그널링된 장소가 아니라 outer가 전송된 지점으로 제어가 리턴될 것이란 점이다.
passResume := [[ Warning signal . 1 ] "resume to here"
on: Warning
do: [ :ex | ex pass . 2 ]]
on: Warning
do: [ :ex | ex resume ].
passResume → 1 "resumes to original signal point"
outerResume := [[ Warning signal . 1 ]
on: Warning
do: [ :ex | ex outer . 2 ]] "resume to here"
on: Warning
do: [ :ex | ex resume ].
outerResume → 2 "resumes to where outer was sent"
예외와 ensure:/ifCurtailed: 상호작용
예외가 어떻게 작용하는지 보았으니 예외와 ensure: 또는 ifCurtailed: 구문 간 상호 작용을 제시하겠다. ensure: 또는 ifCurtailed: 블록은 예외 핸들러가 실행되고 나서야 실행된다. ensure: 인자는 항상 실행되는 반면 ifCurtailed: 인자는 그 수신자 실행이 스택의 언와인딩(unwinding)을 야기한 경우에만 실행된다.
아래 예제가 그러한 행위를 보여준다. 이는 should show first error 다음에 then should show curtailed 를 출력한 후 4를 리턴한다.
[[ 1/0 ]
ifCurtailed: [ Transcript show: 'then should show curtailed'; cr. 6 ]]
on: Error do: [ :e |
Transcript show: 'should show first error'; cr.
e return: 4 ].
먼저 [1/0] 는 제로 오류(0으로 나눗셈)를 발생시킨다. 이러한 오류는 예외 핸들러에 의해 처리된다. 이는 첫 번째 메시지를 출력한다. 이후 값 4를 리턴하고, 수신자가 오류를 야기하였기 때문에 ifCurtailed: 메시지의 인자가 평가되어 두 번째 메시지를 출력한다. ifCurtailed: 는 오류 핸들러나 ifCurtailed: 인자에 의해 표현된 리턴 값을 변경하지 않음을 주목하라.
아래 표현식은, 스택이 언와인딩 되지 않았을 경우 표현식 값이 단순히 리턴되거나 어떤 핸들러도 실행되지 않음을 보여준다. 1이 리턴된다.
[[ 1 ]
ifCurtailed: [ Transcript show: 'curtailed'; cr. 6 "does not display it" ]]
on: Error do: [ :e |
Transcript show: 'error'; cr. "does not display it"
e return: 4 ].
ifCurtailed: 은 스택 이상 행위에 대해 반응하는 watchdog다. 예를 들어, 이전 표현식의 수신자 내에 리턴 문을 추가할 경우 ifCurtailed: 메시지의 인자가 발생할 것이다. 사실상 리턴 문은 메서드에서 정의되지 않았으므로 유효하지 않다.
[[ ^ 1 ]
ifCurtailed: [ Transcript show: 'only shows curtailed'; cr. ]]
on: Error do: [ :e |
Transcript show: 'error 2'; cr. "does not display it"
e return: 4 ].
아래 예제는 오류가 발생하지 않더라도 ensure:가 체계적으로 실행됨을 보여준다. 여기서는 should show ensure 메시지가 표시되고 값으로 1이 리턴된다.
[[ 1 ]
ensure: [ Transcript show: 'should show ensure'; cr. 6 ]]
on: Error do: [ :e |
Transcript show: 'error'; cr. "does not display it"
e return: 4 ].
아래 표현식은 앞에서와 같이 오류가 발생하면 ensure: 인자 이전에 오류와 연관된 핸들러가 실행됨을 보여준다. 여기서 표현식은 should show error first를 출력하고 나서 then should show ensure를 출력한 후 4를 리턴한다.
[[ 1/0 ]
ensure: [ Transcript show: 'then should show ensure'; cr. 6 ]]
on: Error do: [ :e |
Transcript show: 'should show error first'; cr.
e return: 4 ].
마지막으로 아래 표현식은 오류에서 가장 가까운 오류부터 가장 먼 오류까지 오류가 하나씩 실행된 후에 ensure: 인자가 실행됨을 보여준다. 여기서는 error1, error2, then should show ensure 순으로 표시된다.
[[[ 1/0 ] ensure: [ Transcript show: 'then should show ensure'; cr. 6 ]]
on: Error do: [ :e|
Transcript show: 'error 1'; cr.
e pass ]] on: Error do: [ :e |
Transcript show: 'error 2'; cr. e return: 4 ].
예제: Deprecation
Deprecation은 재개 가능한 예외를 이용하여 빌드된 메커니즘의 사례 연구를 제공한다. deprecation은 소프트웨어 재공학 패턴으로서, "오래되어 사라진(deprecated)" 메서드를 표시하도록 해주는데, 즉 향후 배포판에서는 사라질 수 있으며 새 코드에서 사용해서는 안 된다는 의미다. Pharo에서는 아래와 같이 오래되어 사라진 메서드를 표시할 수 있다.
Utilities class>>convertCRtoLF: fileName
"Convert the given file to LF line endings. Put the result in a file with the extention '.lf'"
self deprecated: 'Use ''FileStream convertCRtoLF: fileName'' instead.'
on: '10 July 2009' in: #Pharo1.0 .
FileStream convertCRtoLF: fileName
convertCRtoLF: 메시지가 전송되었을 때 raiseWarning 설정이 true로 된 경우 팝업 창과 함께 알림이 표시되면서 프로그래머가 애플리케이션 실행을 재개할 수 있게 되는데, 이를 그림 13.3에 소개하고 있다 (Settings는 제 5장에서 상세히 설명한 바 있다). 물론 해당 메서드는 오래되어 사라졌기 때문에 현재 Pharo 배포판에선 찾아볼 수 없다. deprecated:on:in: 의 또 다른 전송자를 살펴보라.
deprecation은 몇 가지 단계를 통해 Pharo에서 구현된다. 첫째, Deprecation을 Warning의 하위클래스로서 정의한다. deprecation에 관한 정보를 포함하기 위한 인스턴스 변수를 몇 개 가져야 하는데 Pharo에서는 methodReference, explanationString, deprecationDate, versionString이 해당한다. 따라서 이러한 변수들에 대한 인스턴스 측 initialization 메서드를 정의하고, 그에 해당하는 메시지를 전송하는 클래스 측 인스턴스 생성 메서드도 정의할 필요가 있다.
새로운 예외 클래스를 정의할 때는 isResumable, description, defaultAction 의 오버라이드를 고려해야 한다. 이런 경우 첫 두 메서드에 상속된 구현이 괜찮다.
- isResumable 은 Exception으로부터 상속되고, true를 응답한다.
- description은 Exception으로부터 상속되고, 적절한 텍스트 설명을 응답한다.
하지만 defaultAction의 구현은 필수적으로 오버라이드 해야 하는데, 일부 설정에 따라 달라지길 원하기 때문이다. Pharo의 구현을 살펴보자.
Deprecation>>defaultAction
Log ifNotNil: [:log| log add: self].
self showWarning ifTrue:
[Transcript nextPutAll: self messageText; cr; flush].
self raiseWarning ifTrue:
[super defaultAction]
첫 번째 개인설정은 단순히 Transcript 상에 경고 메시지가 나타나도록 야기할 뿐이다. 두 번째 개인설정은 예외의 시그널링을 요청하는데, 이는 super를 전송하는 defaultAction을 이용해 이루어진다.
Object에서 몇 가지 편의 메서드도 구현할 필요가 있으며, 일례를 들자면 다음과 같다.
Object>>deprecated: anExplanationString on: date in: version
(Deprecation
method: thisContext sender method
explanation: anExplanationString
on: date
in: version) signal
예제: Halt 구현
Pharo By Example 의 Debugger 장에서 논한 바와 같이 스몰토크 메서드 내에서는 메시지 전송 self halt를 코드로 삽입하는 것이 중단점을 설정하는 가장 일반적인 방법이다. Object에서 구현되는 halt 메서드는 중단점의 위치에서 디버거를 열기 위해 예외를 사용하는데, 이는 아래와 같이 정의된다.
Object>>halt
"This is the typical message to use for inserting breakpoints during
debugging. It behaves like halt:, but does not call on halt: in order to
avoid putting this message on the stack. Halt is especially useful when
the breakpoint message is an arbitrary one."
Halt signal
Halt는 Exception의 직계 하위클래스다. Halt 예외는 재개 가능한데, 즉 Halt가 시그널링된 후 실행을 계속하는 것이 가능하다는 의미다.
Halt는 예외가 포착되지 않을 경우 (예: 실행 스택 어디에서도 Halt에 대한 예외 핸들러를 찾을 수 없는 경우) 실행할 액션을 명시하는 defaultAction 메서드를 오버라이드한다.
Halt>>defaultAction
"No one has handled this error, but now give them a chance to decide
how to debug it. If no one handles this then open debugger
(see UnhandedError-defaultAction)"
UnhandledError signalForException: self
위의 코드는 핸들러가 존재하지 않음을 전달하는 새로운 예외, UnhandledError를 시그널링한다. UnhandledError의 defaultAction은 디버거를 여는 것이다.
UnhandledError>>defaultAction
"The current computation is terminated. The cause of the error should be logged or
reported to the user. If the program is operating in an interactive debugging
environment the computation should be suspended and the debugger activated."
^ UIManager default unhandledErrorDefaultAction: self exception
MorphicUIManager>>unhandledErrorDefaultAction: anException
^ Smalltalk tools debugError: anException.
여러 개의 메시지 다음에 디버거가 열린다.
Process>>debug: context title: title full: bool
^ Smalltalk tools debugger
openOn: self
context: context
label: title
contents: nil
fullView: bool.
특정 예외
Pharo에서 Exception 클래스는 그림 13.4과 같이 10개의 직계 하위클래스를 갖는다. 그림에서 가장 먼저 눈에 띄는 점은 Exception 계층구조가 약간 엉망이라는 점이며, Pharo가 향상되면서 세부 내용이 변경될 것으로 예상할 수 있다.
두 번째로 눈에 들어오는 내용은 두 개의 하위 계층구조, Error와 Notification이 존재한다는 사실이다. 오류들은 프로그램이 이상한 상황에 빠졌음을 알려준다. 반대로 Notifications는 이벤트가 발생하였다고 말하지만 그것이 비정상적이라는 가정은 없다. 따라서 Notification이 처리되지 않으면 프로그램은 계속해서 실행될 것이다. Notification의 중요한 서브클래스로 Warning이 있는데, 경고는 시스템의 다른 부분들이나 사용자에게 비정상적이지만 위험하지 않은 행위를 통지하는 데에 사용된다.
재개 가능성이란 프로퍼티는 대개 계층구조에서 예외의 위치와 직교를 이룬다. 일반적으로 Errors는 재개 가능하지 않지만 그 서브클래스들 중 10개는 재개 가능하다. 예를 들어, MessageNotUnderstood는 Error의 서브클래스지만 재개 가능하다. TestFailures는 재개 가능하지 않지만, 이름에서 알 수 있듯이 ResumableTestFailures는 재개 가능하다.
재개 가능성은 private Exception 메서드인 isResumable에 의해 제어되는데, 아래에 예를 소개하겠다.
Exception new isResumable → true
Error new isResumable → false
Notification new isResumable → true
Halt new isResumable → true
MessageNotUnderstood new isResumable → true
알다시피 대략 모든 예외의 2/3은 재개 가능하다.
Exception allSubclasses size → 160
(Exception allSubclasses select: [:each | each new isResumable]) size → 79
예외의 새 서브클래스를 선언할 경우 그 프로토콜에서 isResumable 메서드를 검색하여 자신의 예외 구문에 적절한 것으로 오버라이드해야 한다.
일부 상황에서는 예외를 재개하는 의미가 없을 것이다. 그러한 경우, 당신은 재개 가능하지 않은 서브클래스를 시그널링해야 하는데, 기존에 존재하는 서브클래스를 사용해도 되고 새로 생성해도 좋다. 그 외의 상황에서는 필요가 없는 핸들러 없이 예외를 재개하는 방법은 언제든 괜찮을 것이다. 사실 이는 알림(notification)을 특징 짓는 또 다른 방법을 제공하는데, Notification은 시스템 상태를 먼저 수정하지 않고 안전하게 재개할 수 있는 재개 가능 Exception 이다. 시스템의 상태가 먼저 어떤 방법으로든 수정될 경우에만 예외를 재개하는 것이 안전한 경우도 자주 있을 것이다. 따라서 재개 가능 예외를 시그널링할 경우에는 예외를 재개하기 전에 예외 핸들러가 그러한 일을 실행할 것임을 분명히 확신해야 한다.
새로운 예외를 정의할 때. 기존 예외를 재사용하지 않고 새로운 예외를 정의하는 것이 가치가 있는 경우가 언제인지 결정하기란 까다로운 문제다. 몇 가지 경험적 지식을 알려주겠다. 첫째, 예외적 상황에 대해 적절한 해결책이 있다면 평가해야 한다. 둘째, 예외적 상황이 처리되지 않을 경우 구체적인 기본 행위가 필요하다. 셋째, 예외 사례를 다루기 위해 더 많은 정보를 보관할 필요가 있다.
예외를 사용하면 안 되는 경우
Pharo가 예외 처리를 갖고 있다고 해서 그 사용이 언제나 적절하다는 결론을 내려선 안 된다. 이번 장의 서론 내용을 상기시켜보면, 예외 처리란 예외적 상황을 위한 것임을 언급한 바 있다. 따라서 예외를 사용하기 위한 첫 번째 규칙은 일반적인 실행에서 타당하게 발생할 것으로 예상되는 상황에서는 예외를 사용하지 않는 것이다.
물론 라이브러리를 작성한다면 라이브러리가 사용되는 컨텍스트에 따라 그 기준이 좌우될 것이다. 구체적으로 설명하기 위해 Dictionary를 예로 살펴볼 것인데, aDictionary at: aKey 는 aKey 가 존재하지 않을 경우 Error를 시그널링할 것이다. 하지만 이 오류에 대해 핸들러를 작성해선 안 된다! 자신의 애플리케이션의 로직에서 dictionary 내에 키가 존재하지 않을 가능성이 있다면 at: aKey ifAbsent: [remedial action]를 대신 사용해야 한다. 사실 Dictionary>>at: 는 Dictionary>>at:ifAbsent: 를 이용해 구현된다. aCollection detect: aPredicateBlock 도 유사한데, 이를 이용해도 충족시키지 못할 가능성이 있을 경우 aCollection detect: aPredicateBlock ifNone: [remedial action]를 사용해야 한다.
예외를 시그널링하는 메서드를 작성할 때는 remedial block을 추가 인자로서 취하는 대안적 메서드도 제공해야 하는지의 여부를 고려하고, 일반 액션을 완료할 수 없는지를 평가해야 한다. 이러한 기법은 closures를 지원하는 프로그래밍 언어라면 어디서든 사용 가능하지만, 스몰토크에서는 그 모든 제어 구조에 대해 closures를 사용하므로 특히 사용하기가 자연스럽다.
예외 처리를 피하는 또 다른 방법은 예외를 시그널링할 수 있는 메시지를 전송하기 전에 예외의 전제조건(precondition)을 테스트하는 방법이 있다. 예를 들어, 메서드 13.1에서 우리는 perform: 을 이용해 객체로 메시지를 전송하고, 그에 뒤따를 수 있는 MessageNotUnderstood 오류를 처리하였다. perform: 을 실행하기 전에 메시지가 이해되었는지 확인하는 더 간단한 방법으로 다음을 소개하겠다.
메서드 13.2: Object>>performAll: revisited
performAll: selectorCollection
selectorCollection
do: [:each | (self respondsTo: each)
ifTrue: [self perform: each]]
메서드 13.2에 대한 주요 반론으로 효율성을 들 수 있다. respondsTo: 의 구현은 s 가 이해될 것인지 알아내기 위해 대상의 메서드 dictionary에서 s를 검색해야 한다. 응답이 yes일 경우 perform: 은 다시 검색할 것이다. 뿐만 아니라 첫 번째 구현은 스몰토크에서 구현되지만 가상 머신에선 구현되지 않는다. 코드가 만일 성능 결정적인 루프에 있을 경우 문제가 될지도 모른다. 하지만 메시지의 컬렉션이 사용자 상호작용에서 온다면 performAll: 의 속도는 문제가 되지 않을 것이다.
예외 구현
지금까지는 예외가 가상 머신 수준에서 어떻게 구현되는지 깊이 설명하지 않고 예외의 사용만 설명해왔다. 예외를 사용하려면 어떻게 구현되어야 하는지 모르고 있으므로 본 저서를 처음 읽는 독자라면 이번 절을 건너뛰어도 좋다. 하지만 가상 머신 수준에서 예외가 어떻게 구현되는지 궁금하고 알고 싶다면 꼭 읽기를 바란다. 메커니즘은 꽤 단순하여서 그것이 어떻게 작동하는지 알만한 가치가 있다. 가상 머신 수준에서 예외가 어떻게 구현되는지를 살펴보고 그 정보를 보관하기 위해 스택 실행 요소(컨텍스트)를 사용해보자.
핸들러 보관하기. 먼저 예외 클래스와 그에 연관된 핸들러가 어떻게 저장되는지, 이러한 정보를 런타임 시 어떻게 발견하는지 이해할 필요가 있다. BlockClosure 클래스에 정의된 on:do: 라는 중심(central) 메서드의 정의를 살펴보자.
BlockClosure>>on: exception do: handlerAction
"Evaluate the receiver in the scope of an exception handler."
| handlerActive |
<primitive: 199>
handlerActive := true.
^self value
이 코드는 두 가지를 알려주는데, 첫째, 해당 메서드는 프리미티브로서 구현되어서 메서드가 호출되면 가상 머신의 원시 연산이 실행된다. VM 프리미티브는 보통 리턴하지 않으며, 프리미티브를 연속적으로 실행할 경우 <primitive: n> 명령어를 포함한 메서드를 종료시키고 프리미티브의 결과를 응답한다. 따라서 프리미티브를 따르는 스몰토크 코드는 두 가지 목적을 달성하는데, 이는 프리미티브가 하는 일을 기록하고, 프리미티브가 실패 시 실행되는 역할을 하는 것이다. on:do: 는 단순히 임시 변수 handlerActive를 true로 설정한 후 수신자(물론 블록에 해당)를 평가하는 일만 한다.
이는 놀랍도록 간단하고 다소 헷갈리기도 한다. on:do: 메서드의 인자는 어디 보관될까? 이에 대한 답은 인스턴스들이 실행 스택 요소를 표현하는 MethodContext 클래스의 정의를 살펴보면 얻을 수 있겠다. 제 14장에서 설명한 바와 같이 컨텍스트(다른 언어에선 활성 레코드 또는 스택 프레임이라고도 불리는)는 특정 실행 지점을 나타낸다 (프로그램 카운터를 유지하고, 실행해야 할 다음 명령어, 이전 컨텍스트, 인자, 수신자를 가리킨다).
ContextPart variableSubclass: #MethodContext
instanceVariableNames: 'method closureOrNil receiver'
classVariableNames: ''
poolDictionaries: ''
category: 'Kernel-Methods'
여기서는 예외 클래스나 핸들러를 보관할 인스턴스 변수도 없을 뿐더러 슈퍼클래스에서는 그들을 보관할 공간조차 없다. 하지만 MethodContext는 variableSubclass로서 정의됨을 주목하라. 이는 명명된 인스턴스 변수 뿐만 아니라 해당 클래스의 인스턴스들 또한 몇몇 색인된 슬롯을 갖고 있음을 의미한다. 사실상 모든 MethodContext는 그것이 나타내는 호출의 메서드에서 각 인자마다 색인된 슬롯을 갖고 있다. 메서드의 임시 변수마다 추가 색인 슬롯도 있다.
해당하는 경우, on:do: 메시지의 인자는 스택 실행 인스턴스의 색인 변수에 보관된다. 이를 검증하기 위해 아래의 코드 조각을 평가하라.
| exception handler |
[ thisContext explore.
self halt.
exception := thisContext sender at: 1.
handler := thisContext sender at: 2.
1 / 0]
on: Error
do: [:ex | 666].
^ {exception. handler} explore
보호 블록에서 우리는 thisContext sender를 이용해 보호 블록 실행을 표현하는 스택 요소를 질의한다. 이러한 실행은 on:do: 메시지 실행에 의해 트리거된다. 마지막 행은 예외 클래스와 예외 핸들러를 포함하는 2-요소 배열을 검색한다.
halt를 사용하여 이상한 결과를 얻고 보호 블록 내부를 검사한다면 가상 머신에 의해 재활용되는 컨텍스트에 주의를 기울여라. thisContext에서 explorer를 열 경우 컨텍스트 전송자가 사실상 on:do: 메서드의 실행임을 보여줄 것이다.
아래 코드를 이용해 explorer를 얻을 수 있을 뿐만 아니라 예외 클래스와 핸들러는 메서드 컨텍스트 객체의 첫 번째와 두 번째 가변 인스턴스 변수에 보관된다는 사실을 볼 수 있다 (메서드 컨텍스트란 실행 스택 요소를 나타낸다).
[thisContext sender explore] on: Error do: [:ex|].
on:do: 실행은 예외 클래스와 그 핸들러를 메서드 컨텍스트(실행 스택 프레임) 상에 보관함을 볼 수 있다. 이는 on:do: 에 한정된 것은 아니지만 여느 메시지 실행이든 인자를 스택 프레임에 보관한다는 사실을 주목하라.
핸들러 찾기. 정보가 어디에 보관되는지 알았으니 런타임 시 정보를 어떻게 찾는지 살펴보자.
primitive 199(on:do: 에 의해 사용되는)는 쓰기가 복잡하다고 생각할지 모르겠다. 하지만 primitive 199는 항상 실패하기 때문에 너무 평범하다! 프리미티브는 항상 실패할 것이고, on:do: 에 대한 스몰토크 body가 항상 실행된다. 하지만 <primitive: 199>라는 표기법은 실행 컨텍스트를 유일한 방법으로 표시한다.
프리미티브의 소스 코드는 VMMaker SqueakSource 패키지의 Interpreter>>primitiveMarkHandlerMethod 에서 찾을 수 있다.
primitiveMarkHandlerMethod
"Primitive. Mark the method for exception handling. The primitive must fail after
marking the context so that the regular code is run."
self inline: false.
^self primitiveFail
on:do: 메서드가 언제 실행되는지 알았으니 스택 프레임을 구성하는 MethodContext가 태그되고 핸들러와 예외 클래스가 그 곳에 보관된다.
이제 예외가 스택 윗방향으로 시그널링되면 signal 메서드는 적절한 핸들러를 찾기 위해 스택을 검색하는데, 이러한 과정은 모두 아래 코드로부터 발생한다.
Exception>>signal
"Ask ContextHandlers in the sender chain to handle this signal.
The default is to execute and return my defaultAction."
signalContext := thisContext contextTag.
^ thisContext nextHandlerContext handleSignal: self
ContextPart>>nextHandlerContext
^ self sender findNextHandlerContextStarting
findNextHandlerContextStarting 메서드는 프리미티브로서 구현되고 (number 197), 그 body는 그것이 하는 일을 설명한다. 스택 프레임이 마치 on:do: 메서드의 실행으로 인해 생성된 컨텍스트인 것처럼 보인다 (프리미티브 번호가 199번인 것처럼 보인다). 해당 컨텍스트를 응답하는 경우는 아래와 같다.
ContextPart>>findNextHandlerContextStarting
"Return the next handler marked context, returning nil if there
is none. Search starts with self and proceeds up to nil."
| ctx |
<primitive: 197>
ctx := self.
[ ctx isHandlerContext ifTrue: [^ctx].
(ctx := ctx sender) == nil ] whileFalse.
^nil
MethodContext>>isHandlerContext
"is this context for method that is marked?"
^method primitive = 199
findNextHandlerContextStarting 가 제공하는 메서드 컨텍스트는 모든 예외 처리 정보를 포함하므로 예외 클래스가 현재 예외를 처리하기에 적절한지 확인할 수 있다. 만일 적합하다면 연관된 핸들러를 실행할 수 있고, 그렇지 않을 경우 검색(look-up)이 계속된다. 이 모든 것은 handleSignal: 메서드에서 구현된다.
ContextPart>>handleSignal: exception
"Sent to handler (on:do:) contexts only. If my exception class (first arg) handles
exception then execute my handle block (second arg), otherwise forward this
message to the next handler context. If none left, execute exception's defaultAction
(see nil>>handleSignal:)."
| val |
(((self tempAt: 1) handles: exception) and: [self tempAt: 3]) ifFalse: [
^ self nextHandlerContext handleSignal: exception].
exception privHandlerContext: self contextTag.
self tempAt: 3 put: false. "disable self while executing handle block"
val := [(self tempAt: 2) valueWithPossibleArgs: {exception}]
ensure: [self tempAt: 3 put: true].
self return: val. "return from self if not otherwise directed in handle block"
해당 메서드가 tempAt: 를 이용해 예외 클래스로 접근하여 그것의 예외 처리 여부를 묻는 방식을 주목하라. tempAt: 3은 어떤가? 이것은 on:do: 메서드의 handlerActive 임시 변수다. handlerActive가 true인지 테스트한 후 false로 설정하면 스스로 시그널링하는 예외를 핸들러가 처리하지 않도록 보장한다. handleSignal 의 마지막 액션으로서 전송되는 return: 메시지는 self 위에서 스택 프레임을 제거함으로써 실행 스택의 "언와인딩"을 책임진다.
요약하자면, signal 메서드는 약간의 가상 머신의 도움을 받아 적절한 예외 클래스를 이용해 on:do: 메시지에 해당하는 컨텍스트를 찾는다. 실행 스택은 여느 객체와 동일하게 조작할 수 있는 Context 객체들로 구성되기 때문에 스택은 언제든 단축될 수 있다. 이는 스몰토크의 유연성을 보여주는 최고의 예이다.
Ensure: 의 구현
이제 ensure: 메서드의 구현을 살펴볼 것을 권한다.
먼저 unwind 블록이 어떻게 보관되는지와 이러한 정보를 런타임 시 어떻게 찾는지를 이해할 필요가 있겠다. BlockClosure에 정의된 중심 메서드 ensure: 의 정의를 살펴보자.
ensure: aBlock
"Evaluate a termination block after evaluating the receiver, regardless of
whether the receiver's evaluation completes. N.B. This method is*not*
implemented as a primitive. Primitive 198 always fails. The VM uses prim
198 in a context's method as the mark for an ensure:/ifCurtailed: activation."
| complete returnValue |
<primitive: 198>
returnValue := self valueNoContextSwitch.
complete ifNil: [
complete := true.
aBlock value ].
^ returnValue
<primitive: 198 >는 앞 절에서 살펴본 <primitive: 199 > 와 같은 방식으로 작동한다. 이는 항상 실패하지만 이것이 존재할 경우 실행하는 컨텍스트를 유일한 방식으로 표시한다. 게다가 unwind 블록은 예외 클래스 및 그에 연관된 핸들러와 동일한 방식으로 보관된다. 좀 더 분명히 말하자면 ensure: 메서드 실행의 컨텍스트에 (스택 프레임) 보관되는데, thisContext sender tempAt: 1을 통해 블록으로부터 접근이 가능하다.
블록이 실패하지 않고 non-local 리턴을 갖지 않은 경우, ensure: 메시지 구현은 매우 이해하기 쉽다. 메시지는 블록을 평가하고, returnValue 변수 내에 결과를 저장하며, 인자 블록을 평가하고, 마지막으로 이전에 저장된 블록의 결과를 리턴한다. complete 변수는 인자 블록이 두 번 실행되는 것을 피하기 위해 존재할 뿐이다.
실패 블록 확보하기. ensure: 메시지는 블록이 실패하더라도 인자 블록을 실행할 것이다. 아래 예제에서 ensureWithOnDo 메시지는 2를 리턴하고 1을 실행한다. 그 다음 절에서 우리는 블록이 어디에서 무엇을 실제로 리턴하는지를 비롯해 블록이 어떤 순서로 실행되는지를 주의 깊게 살펴볼 것이다.
Bexp>>ensureWithOnDo
^[ [ Error signal ] ensure: [ 1 ].
^3 ] on: Error do: [ 2 ]
구현을 살펴보기 전에 간략한 예를 들어보자. 실패 Block을 확보하는 4개의 블록과 1개의 메서드를 정의한다.
Bexp>>mainBlock
^[ self traceCr: 'mainBlock start'.
self failingBlock ensure: self ensureBlock.
self traceCr: 'mainBlock end' ]
Bexp>>failingBlock
^[ self traceCr: 'failingBlock start'.
Error signal.
self traceCr: 'failingBlock end' ]
Bexp>>ensureBlock
^[ self traceCr: 'ensureBlock value'.
#EnsureBlockValue ]
Bexp>>exceptionHandlerBlock
^[ self traceCr: 'exceptionHandlerBlock value'.
#ExceptionHandlerBlockValue ]
Bexp>>start
| res |
self traceCr: 'start start'.
res := self mainBlock on: Error do: self exceptionHandlerBlock.
self traceCr: 'start end'.
self traceCr: 'The result is : ', res, '.'.
^ res
Bexp new start 를 실행하면 아래가 출력된다 (호출 흐름을 강조하기 위해 들여쓰기를 적용하였다).
start start
mainBlock start
failingBlock start
exceptionHandlerBlock value
ensureBlock value
start end
The result is: ExceptionHandlerBlockValue.
세 가지 사항이 중요하다. 첫째, 실패하는 블록과 메인 블록은 signal 메시지 때문에 완전히 실행되지 않는다. 둘째, 실행 블록이 ensure 블록 이전에 실행된다. 마지막으로, start 메서드는 예외 핸들러 블록의 결과를 리턴할 것이다.
이것이 어떻게 작용하는지 이해하기 위해 예외 구현의 끝 부분을 살펴봐야겠다. handleSignal 메서드에 관한 이전 설명을 끝마친다.
ContextPart>>handleSignal: exception
"Sent to handler (on:do:) contexts only. If my exception class (first arg) handles
exception then execute my handle block (second arg), otherwise forward this
message to the next handler context. If none left, execute exception's defaultAction
(see nil>>handleSignal:)."
| val |
(((self tempAt: 1) handles: exception) and: [self tempAt: 3]) ifFalse: [
^ self nextHandlerContext handleSignal: exception].
exception privHandlerContext: self contextTag.
self tempAt: 3 put: false. "disable self while executing handle block"
val := [(self tempAt: 2) valueWithPossibleArgs: {exception}]
ensure: [self tempAt: 3 put: true].
self return: val. "return from self if not otherwise directed in handle block"
우리가 제시한 예제라면 Pharo는 실패하는 블록을 실행한 후 다음 <primitive: 199 >로 표시된 핸들러 컨텍스트를 검색할 것이다. 정규적 예외(regular exception)로서 Pharo는 예외 핸들러 컨텍스트를 찾고 exceptionHandlerBlock을 실행한다. handleSignal 메서드는 return: 메서드를 이용해 완료되는데, 아래를 통해 살펴보자.
ContextPart>>return: value
"Unwind thisContext to self and return value to self's sender. Execute any unwind
blocks while unwinding. ASSUMES self is a sender of thisContext"
sender ifNil: [self cannotReturn: value to: sender].
sender resume: value
return: 메시지는 컨텍스트가 전송자를 갖고 있는지 확인할 것이며, 전송자가 없을 경우 Cannotreturn Exception을 전송할 것이다. 이후 해당 컨텍스트의 전송자는 resume: 메시지를 호출할 것이다.
ContextPart>>resume: value
"Unwind thisContext to self and resume with value as result of last send. Execute
unwind blocks when unwinding. ASSUMES self is a sender of thisContext"
| ctxt unwindBlock |
self isDead ifTrue: [self cannotReturn: value to: self].
ctxt := thisContext.
[ ctxt := ctxt findNextUnwindContextUpTo: self.
ctxt isNil
] whileFalse: [
(ctxt tempAt: 2) ifNil:[
ctxt tempAt: 2 put: true.
unwindBlock := ctxt tempAt: 1.
thisContext terminateTo: ctxt.
unwindBlock value].
].
thisContext terminateTo: self.
^ value
이는 ensure: 의 인자 블록이 실행되는 메서드이다. 해당 메서드는 resume: 메서드의 컨텍스트와 on:do: 컨텍스트(본문 예제에서는 start의 컨텍스트)의 전송자인 self사이의 모든 unwind 컨텍스트를 검색한다. 메서드가 언와인딩된 컨텍스트를 찾으면 unwound 블록이 실행된다. 마지막으로 terminateTo: 메시지를 트리거한다.
ContextPart>>terminateTo: previousContext
"Terminate all the Contexts between me and previousContext, if previousContext is on
my Context stack. Make previousContext my sender."
| currentContext sendingContext |
<primitive: 196>
(self hasSender: previousContext) ifTrue: [
currentContext := sender.
[currentContext == previousContext] whileFalse: [
sendingContext := currentContext sender.
currentContext terminate.
currentContext := sendingContext]].
sender := previousContext
기본적으로 해당 메서드는 on:do: 컨텍스트(본문 예제에서는 start의 컨텍스트)의 전송자인 self와 thisContext 사이의 모든 컨텍스트를 종료한다. 뿐만 아니라 thisContext의 전송자가 on:do: 컨텍스트(본문 예제에서는 start의 컨텍스트)의 전송자인 self가 될 것이다. 이것은 프리미티브로서 구현되지만 어떻게 작동하는지는 아래 코드에서 설명한다.
앞서 정의된 ensureWithOnDo 메서드의 실행을 보여주는 그림 13.6에서 무슨 일이 발생하는지 간략하게 살펴보자.
- 왼쪽 코멘트: 메서드가 컨텍스트를 검색할 때 화살표는 메서드가 발견된 컨텍스트를 표시한다.
- 오른쪽 코멘트: 컨텍스트를 검색하는 동안 메서드는 일치하지 않는 컨텍스트를 삭제한다.
non-local 리턴 확보하기. non-local 리턴에 대한 ensure: 의 구현도 존재한다. 기본적으로는 언와인딩된 컨텍스트의 검색은 resume:through: 내 값을 리턴할 때 트리거되는 resume: 메시지에서와 동일한 유형이다.
ContextPart>>resume: value through: firstUnwindCtxt
"Unwind thisContext to self and resume with value as result of last send.
Execute any unwind blocks while unwinding. ASSUMES self is a sender of
thisContext."
| ctxt unwindBlock |
self isDead ifTrue: [self cannotReturn: value to: self].
ctxt := firstUnwindCtxt.
[ctxt isNil] whileFalse:
[(ctxt tempAt: 2) ifNil:
[ctxt tempAt: 2 put: true.
unwindBlock := ctxt tempAt: 1.
thisContext terminateTo: ctxt.
unwindBlock value].
ctxt := ctxt findNextUnwindContextUpTo: self].
thisContext terminateTo: self.
^value
요약
본 장에서는 시그널링해야 하는 예외를 사용하는 방법과 코드에서 발생하는 비정상적 상황을 처리하는 방법을 살펴보았다.
- 예외를 제어-흐름 메커니즘으로 사용하지 말라. 비정상적 상황과 알림용으로 남겨두어라. 예외를 시그널링하기 위한 방법으로, 블록을 인자로서 취하는 메서드를 제공하는 방도를 고려해보라.
- protectedBlock이 비정상적으로 종료되더라도 actionBlock이 실행되도록 확보하기 위해서는 protecBlock ensure: actionBlock 을 사용하라.
- protectedBlock이 비정상적으로 종료될 때에만 actionBlock이 실행되도록 확보하기 위해서는 protecBlock ifCurtailed: actionBlock 을 사용하라.
- 예외는 객체다. 예외 클래스들은 계층구조를 형성하고, 그 계층구조 루트에는 Exception 클래스가 위치한다.
- ExceptionClass(또는 그 서브클래스들 중 하나)의 인스턴스에 해당하는 예외를 포착하기 위해서는 protectedBlock on: ExceptionClass do: handlerBlock 를 사용하라. handlerBlock 은 예외 인스턴스를 유일한 인자로서 취해야 한다.
- 예외는 signal 이나 signal: 메시지 중 하나를 전송함으로써 시그널링된다. signal: 은 그 인자로 설명적 문자열을 취한다. 예외의 설명은 예외로 description을 전송하면 얻을 수 있다.
- 자신의 코드에 메시지 전송 self halt를 삽입하면 중단점(breakpoint)을 설정할 수 있다. 이는 재개 가능한 Halt 예외를 시그널링하며, 기본적으로 이는 중단점이 발생하는 지점에서 디버거를 열 것이다.
- 예외가 시그널링되면 런타임 시스템이 특정 예외 클래스에 대한 핸들러를 실행 스택에서 검색할 것이다. 어떤 핸들러도 발견되지 않을 경우 해당 예외에 대한 defaultAction이 실행될 것이다 (예: 대부분의 경우 디버거가 열릴 것이다).
- 예외 핸들러는 시그널링된 예외로 return: 을 전송하여 보호 블록을 종료할 수 있다. 보호 블록의 값은 return: 으로 제공되는 인자가 될 것이다.
- 예외 핸들러는 시그널링된 예외로 retry를 제공하여 보호 블록을 재시도할 수도 있다. 핸들러는 계속 유효하다.
- 예외 핸들러는 시그널링된 예외로 새 블록을 인자로 하여 retryUsing: 를 전송하여 새 블록을 명시할 수 있다. 이 또한 핸들러는 계속 유효하다.
- Notifications는 핸들러가 특정 액션을 취하지 않고도 안전하게 재개할 수 있는 프로퍼티를 가진 Exception의 서브클래스이다.
감사의 말. 새로운 내용을 제공해준 Vassili Bykov에게 감사드린다. ensure: 와 ifCurtailed: 의 스몰토크 구현을 제공해준 Paolo Bonzini에게도 감사의 마음을 전한다. 또 코멘트와 여러 의견을 제공해준 Hernan Wilkinson, Lukas Renggli, Christopher Oliver, Camillo Bruni, Hernan Wilkinson, Carlos Ferro께도 감사의 말을 전하고 싶다.
Notes
- ↑ 예외를 "발생" 시키거나 "던지는" 것과 같은 의미다. 필수 메시지는 signal 이라 불리므로 그 용어는 이번 장에서만 사용하겠다.