예외와 구조화된 예외 처리의 개념과 역사, OCaml 등에서의 사용 예, 예외의 운영적 의미론, ERS와 이중 연장 CPS 변환, C++·Java의 검사된 예외와 그 한계, Scala/Effekt의 예외 능력(capability) 접근법, 그리고 추가 참고 문헌을 다룬다.
URL: https://xavierleroy.org/control-structures/book/main011.html
Title: Chapter 9 Exceptions
앞서 3.4절에서 언급했듯이, 예외와 예외 핸들러는 C++, Java, Python, OCaml 같은 프로그래밍 언어에서 오류 보고와 함수·메서드의 조기 종료를 위해 널리 쓰이는 메커니즘이다. 이 메커니즘은 두 가지 언어 구성으로 제시된다.
이러한 방식의 예외 처리를 이전의 goto 점프와 닮은 비구조적 예외 처리(예: C의 시그널 핸들러, Basic의 ON ERROR GOTO 지시문)와 구분하기 위해 구조화된 예외 처리(structured exception handling)라고 부른다.
구조화된 예외 처리는 1972년 Lisp의 MacLisp 방언에서 THROW, CATCH, UNWIND-PROTECT 구성과 함께 등장했다. 그러나 이 접근을 널리 알린 것은 Goodenough (1975)의 유명한 논문으로, 이전의 비구조적 오류 처리 메커니즘의 여러 문제를 어떻게 해결하는지 보여주었다. 1975년 CLU가 구조화된 예외 처리를 명시적으로 설계에 포함한 첫 언어였고, 이어서 1978년 LCF ML(SML, OCaml, 기타 strict 함수형 언어들의 조상), 1980년 Ada, 1990년 C++, 1995년 Java 등이 뒤를 이었다.
오류 보고와 처리.다음은 예외 사용을 보여주는 몇 가지 OCaml 예시다. 주된 용도는 오류, 좀 더 일반적으로는 함수가 그 반환 타입에 대한 의미 있는 결과를 낼 수 없는 상황을 보고하는 것이다. 다시 이차방정식 해법을 생각해 보자:
exception No_solution
let quadratic (a: float) (b: float) (c: float) : float * float =
let d = b *. b -. 4. *. a *. c in
if d < 0.0 then raise No_solution;
let d = sqrt d in
((-. b +. d) /. (2. *. a), (-. b -. d) /. (2. *. a))
실근 해가 없다면, quadratic은 No_solution 예외를 발생시킨다. 이 경우는 예외를 포착하여 처리할 수 있다:
try
let (x1, x2) = quadratic a b c in
printf "해: %g %g\n" x1 x2
with No_solution ->
printf "실수 해가 없습니다\n"
오류를 보고하는 다른 방법들도 있다. quadratic 함수는 옵션 타입 (float * float) option 값을 반환할 수 있으며, None은 “해 없음”, Some(x1, x2)는 “x1과 x2가 해임”을 의미하게 할 수 있다. 좀 더 색다르게는, 7.4절에서 보았듯 성공/오류 두 경우를 위한 두 연속체(continuation)를 인자로 받을 수도 있다. 어느 접근이 더 좋은지 합의는 없다. 옵션 타입을 반환하면 9.4절에서 설명하는 미처리 예외 문제를 피할 수 있지만, 예외를 발생시키는 것보다 더 장황하고(코드량), 더 비쌀 수 있다(메모리 할당 관점에서).
조기 종료.예외는 다양한 형태의 “조기 종료(early exit)” 구성을 구현할 수 있다. 예를 들어, C 계열 언어의 break와 continue 문을 지역 예외로 대체할 수 있다:
let exception Break in
let exception Continue in
try
for i = lo to hi do
try
...
raise Break
...
raise Continue
...
with Continue -> ()
done
with Break -> ()
for 루프 본문에서 raise Continue는 현재 반복을 중단하고 다음 반복을 시작하며, raise Break는 루프 전체를 종료한다. 더 일반적으로, 같은 함수 안에서 발생하고 처리되는 예외는 1장과 2장에서 논의한 다단계 종료와 밀접히 대응한다.
예외는 또한 재귀 함수의 중첩 호출을 일찍 빠져나오는 데 사용할 수 있다. 정수 리스트의 곱을 계산한다고 하자. 리스트에서 0을 찾는 순간 결과를 0으로 반환할 수 있다. 이를 구현하는 한 가지 방법은 다음과 같다:
let list_product l =
let exception Zero in
let rec product = function
| [] -> 1
| 0 :: _ -> raise Zero
| n :: l -> n * product l
in
try product l with Zero -> 0
리스트에서 0을 찾으면, 아직 어떤 정수 곱셈도 수행되지 않았다. 곱셈들은 모두 재귀 호출의 반환에 걸려 대기 상태이고, Zero 예외가 발생할 때 모두 건너뛴다.
특별한 결과를 반환하기.다음 예시는(장-위에르 위에의 예시) 함수 호출의 결과와 인자 간에 메모리를 공유하여 매번 새로운 메모리를 할당하지 않도록 예외를 사용하는 방식이다. 이 접근에서, 타입 τ → τ 형태의 어떤 함수든 Unchanged 예외를 발생시켜 그 결과가 인자와 같음을 호출자에게 알릴 수 있고, 따라서 인자를 재사용할 수 있다. 다음 run 결합자는 이러한 스타일로 작성된 함수를 다시 순수한 예외 없는 함수로 되돌린다:
let run fn arg =
try fn arg with Unchanged -> arg
그러나 Unchanged 예외는 쌍, 리스트, 기타 자료구조를 표현하기 위해 할당된 메모리를 재사용하는 "공유 결합자(sharing combinator)"에서 활용될 수 있다. 다음 pair_map 함수는 두 함수 f와 g를 쌍의 두 성분에 각각 적용한다:
let pair_map (f: 'a -> 'a) (g: 'b -> 'b) (p: 'a * 'b) : 'a * 'b =
try
let x = f (fst p) in
(x, run g (snd p))
with Unchanged ->
let y = g (snd p) in
(fst p, y)
f (fst p)와 g (snd p) 둘 다 Unchanged 예외를 발생시키면, pair_map 역시 Unchanged를 발생시켜 쌍 p를 재사용할 수 있음을 의미한다. 이 경우 새 쌍은 할당되지 않는다. 그렇지 않다면, 가능한 한 원래 쌍 p와 많이 공유하는 새로운 쌍이 할당된다. f가 Unchanged를 발생시키면 fst p가 첫 성분으로 재사용되고, g가 Unchanged를 발생시키면 run 결합자 덕분에 snd p가 둘째 성분으로 재사용된다.
같은 기법을 쌍 대신 리스트에 적용하면 다음과 같다:
let rec list_map (f: 'a -> 'a) (l: 'a list) : 'a list =
match l with
| [] -> raise Unchanged
| x :: l ->
try
let x' = f x in
x' :: run (list_map f) l
with Unchanged ->
x :: list_map f l
f가 l의 모든 원소에서 Unchanged를 발생시키면, list_map도 Unchanged를 발생시켜 전체 리스트 l을 재사용할 수 있음을 알린다. 그렇지 않다면, 입력 리스트 l의 가능한 가장 긴 접미사를 재사용하는 리스트가 반환된다. 더 정확히, l이 [x1; …; xn]이고 f가 xi, …, xn에서 Unchanged를 발생시킨다고 하자. 그러면 l의 접미사 l’ = [xi; …; xn]에 대한 list_map f의 재귀 호출은 Unchanged를 발생시키므로, run (list_map f) l’은 l’을 반환한다. 그런 다음 f x1, …, f x i−1을 담기 위해 i−1개의 새 리스트 셀만 할당된다.
같은 정도의 공유는 예외 대신 타입 τ → τ shared 형태의 함수를 사용해도 달성할 수 있다. 여기서 타입 shared는 다음과 같이 정의한다:
type 'a shared = Changed of 'a | Unchanged
그러나 이 대안적 접근은(Changed 결과의) 단명 할당을 유발하는데, 이는 예외 기반 접근에서는 필요 없다. 게다가, 일반적인 τ → τ 함수를 공유를 보존하는 τ → τ shared 함수로 바꾸려면 상당한 재작성 작업이 필요하다. 반면, raise Unchanged 구문은 기존 함수에 점진적으로 추가할 수 있다.
그림 5.4의 FUN 언어를 raise와 단순 형태의 try로 확장하자:
식: e ::= c ∣ x ∣ λ x . e ∣ e1 e2 ∣ raise e 값 e를 갖는 예외를 발생시킴 ∣ try e1 with x → e2 예외를 처리함
여기서 try e1 with x → e2는 e1의 평가 중 발생하는 모든 예외를 포착한다. 예외 값을 x에 바인딩하고 e2를 평가한다. x를 판별하여 이 예외를 처리할지, 아니면 raise x로 다시 발생시킬지 결정하는 것은 e2의 책임이다. 예를 들어, 문자열을 예외 값으로 사용한다면, 이름이 "E"인 예외만 처리하고 다른 예외는 다음처럼 처리할 수 있다:
try e1 with x → if x = "E" then e2 else raise x
OCaml과 SML에서 예외 값은 확장 가능한 데이터타입 exn에 속하며, 각 예외는 이 타입의 생성자에 대응한다. FUN을 데이터 생성자와 패턴 매칭으로 확장한다고 가정하면, 예외 E만 처리하고 다른 예외는 처리하지 않는 코드는 다음과 같다:
try e1 with x → match x with E → e2 ∣ _ → raise x
try의 운영적 의미론은 다음 두 가지 헤드-축약(head-reduction) 규칙으로 깔끔하게 포착된다:
(try v with x → e2) →ε v (try raise v with x → e2) →ε e2 [x := v]
첫 번째 규칙은 try 본문의 평가에서 예외가 발생하지 않는 경우에 해당한다. 두 번째 규칙은 자명하게 예외 v를 발생시키는 평가에 해당한다. 여기에 더해 예외를 “위로” 전파하는 헤드-축약 규칙이 필요하다:
(raise v) e →ε raise v v′ (raise v) →ε raise v if raise v then e2 else e3 →ε raise v raise (raise v) →ε raise v
축약 맥락(reduction context)은 try 본문과 raise의 인자에서도 축약이 가능하도록 확장되어야 한다:
축약 맥락: C ::= [ ] ∣ C e ∣ v C ∣ if C then e1 else e2 ∣ try C with x → e2 ∣ raise C
8.6절에서 사용한 경계자 없는 부분맥락과 유사하게, try 없는 축약 부분맥락 D를 사용하면 예외 전파 규칙이 필요 없어진다:
try-없는 축약 맥락: D ::= [ ] ∣ D e ∣ v D ∣ if D then e1 else e2 ∣ raise D
그렇다면, 위의 예외 전파 규칙들에 의해 raise v로 축약되는 모든 부분식은 적절한 try-없는 맥락 D에 대해 D[raise v]로 쓸 수 있다. 따라서 try의 의미를 표현하기 위해 다음 두 가지 헤드-축약 규칙이면 충분하다:
(try v with x → e2) →ε v (try D[raise v] with x → e2) →ε e2 [x := v]
예외에 의미를 부여하는 또 다른 방법은 순수 함수형 언어로의 변환이다. 여기서는 하나는 합 타입을 사용하는 방법, 다른 하나는 쌍의 연속체를 사용하는 두 가지 변환을 보인다.
ERS 변환. 예외를 발생시키는 대신, 함수가 다음 합 타입의 값을 반환하도록 할 수 있다:
type 'a res = V of 'a | E of exn
함수는 정상적으로 값 v를 생성하면 V v를, 예외 x로 일찍 중단하면 E x를 반환한다. 여기서 exn은 예외 값의 타입을 뜻한다.
이러한 "예외-반환 방식"(exception-returning style, ERS)은 프로그램 변환으로 체계화할 수 있다: ERS 변환 ℰ. 식 e의 변환 ℰ(e)는 res 타입의 값을 계산하며, 이는 정상 종료의 V 또는 예외로 인한 비정상 종료의 E 중 하나다.
ℰ(c) = V c ℰ(x) = V x ℰ(λ x . e) = V(λ x . ℰ(e)) ℰ(e1 e2) = match ℰ(e1) with E x → E x ∣ V f → match ℰ(e2) with E x → E x ∣ V v → f v ℰ(if e1 then e2 else e3) = match ℰ(e1) with E x → E x ∣ V v → if v then ℰ(e2) else ℰ(e3) ℰ(raise e) = match ℰ(e1) with E x → E x ∣ V v → E v ℰ(try e1 with x → e2) = match ℰ(e1) with E x → ℰ(e2) ∣ V v → V v
try를 제외한 모든 구성에서, 부분식이 낸 E 결과는 즉시 해당 식의 결과로 반환되어 예외를 위로 전파한다. raise e의 경우, e의 정상 값 V v는 예외 값 E v로 바뀌어 예외 발생을 구체화한다. try e1 with x → e2의 경우, e1의 정상 값 V v는 try의 값으로 그대로 반환되고, 예외 값 E x는 e2의 평가를 유발한다.
이중 연장 CPS 변환. 7.4절의 예가 보여주듯, 연속 전달 스타일(CPS)로 작성된 함수는 성공과 오류, 두 가지 종료 방식을 나타내는 두 연속체를 받을 수 있다.
이 기법은 6.5절의 호출-구문(call-by-value) CPS 변환의 변종인 "이중 연장(double-barreled) CPS 변환"으로 일반화할 수 있다. 식 e의 변환 𝒞₂(e)는 두 연속체를 인자로 받는다. 하나는(e의 값에 적용되는 k) e가 예외 없이 평가되면 사용되고, 다른 하나는(e가 예외를 발생시키면 호출되는 k′) 오류 시 사용된다. 코어 언어 구성에 대해, k는 6.5절의 호출-구문 CPS 변환 𝒞에서처럼 사용되고, k′는 변함없이 하위 계산들에 전파된다:
𝒞₂(c) = λ k . λ k′ . k c 𝒞₂(x) = λ k . λ k′ . k x 𝒞₂(λ x . e) = λ k . λ k′ . k(λ x . 𝒞₂(e)) 𝒞₂(e1 e2) = λ k . λ k′ . 𝒞₂(e1)(λ v1 . 𝒞₂(e2)(λ v2 . v1 v2 k k′) k′) k′ 𝒞₂(if e1 then e2 else e3) = λ k . λ k′ . 𝒞₂(e1)(λ v1 . if v1 then 𝒞₂(e2) k k′ else 𝒞₂(e3) k k′) k′
예외 발생은 항상 두 번째 연속체 k′를 호출한다. 인자로 예외 값을 넘기거나, 식 e 자체가 예외를 발생시켰을 수 있다:
𝒞₂(raise e) = λ k . λ k′ . 𝒞₂(e) k′ k′
try…with 예외 핸들러는 오류 연속체 k′를 바꾸어 지정된 핸들러를 호출하도록 만든다:
𝒞₂(try e1 with x → e2) = λ k . λ k′ . 𝒞₂(e1) k (λ x . 𝒞₂(e2) k k′)
변환들 사이의 동형(isomorphism). 기본 타입 ι의 식 e를 생각하자. ERS 변환 ℰ(e), 이중 연장 CPS 변환 𝒞₂(e), 그리고 ERS 변환의 CPS 변환 𝒞(ℰ(e))의 타입은 다음과 같다:
ℰ(e): ι + exn 𝒞₂(e): ∀ α . (ι → α) → (exn → α) → α 𝒞(ℰ(e)): ∀ α . ((ι + exn) → α) → α
여기서 ι + exn은 ERS 결과의 타입 ι res를 나타낸다. (프로그램 변환이 타입에 미치는 보다 일반적인 논의는 13.2절 참조.)
𝒞₂(e)의 타입은 타입 ι + exn의 "처치(Church) 부호화" 타입이다. 이 부호화에서, 타입 ι + exn의 값은 그 제거자(eliminator)로 표현되며, 이는 두 함수를 받는 함수다. 하나는 ι 경우를 다루고(타입 ι → α), 다른 하나는 exn 경우를 다룬다(타입 exn → α). 실제로, 𝒞₂(e) k1 k2는 다음과 같이 동작한다:
match ℰ(e) with V v → k1 v ∣ E x → k2 x
마찬가지로, 𝒞(ℰ(e))에서 연속체 타입으로 나타나는 (ι + exn) → α는 (ι → α) × (exn → α) 타입과 동형이다. 즉, ι + exn 합 타입의 인자를 받는 하나의 연속체를 받는 것은 ι 인자를 받는 연속체 하나와 exn 인자를 받는 연속체 하나, 이렇게 두 연속체를 받는 것과 동등하다. 더 정확히, 𝒞₂(e) k1 k2는 다음과 같이 동작한다:
𝒞(ℰ(e)) (λ x. match x with V v → k1 v ∣ E x → k2 x)
예외로 프로그래밍할 때 흔한 실수는 프로그램이 발생시킬 수 있는 예외를 처리하는 것을 잊는 것이다. 프로그램이 실제로 이 예외를 발생시키면, 급작스럽게 종료한다. 이러한 "미처리 예외(uncaught exceptions)"는 예를 들어 OCaml로 작성된 프로그램에서 흔한 실패 원인이다.
이 위험을 줄이기 위해 일부 프로그래밍 언어는 "검사된 예외(checked exceptions)"를 지원한다:
검사된 예외의 일반적인 생각은, 함수가 발생시킬 수 있는 예외가 인자 및 결과 타입과 마찬가지로 그 함수의 인터페이스의 일부라는 것이다.
CLU의 검사된 예외.구조화된 예외 처리를 대중화한 언어인 CLU는 모든 함수에 예외 선언을 요구한다. 다음 예제가 보여준다:
sign = proc (x: int) returns(int) signals(zero, neg(int))
if x < 0 then signal neg(x)
elseif x = 0 then signal zero
else return(x)
end
end sign
함수에서 빠져나가는(탈출하는) 모든 예외는 그 함수의 signals 절에 선언되어 있어야 한다. 더 나아가, 그러한 예외는 반드시 그 함수를 호출한 쪽에서 처리해야 한다. 핸들러가 발견될 때까지 호출 스택 위로 예외가 자동 전파되지 않는다. 호출자가 예외를 위로 전파하고자 한다면, 명시적으로 예외를 다시 발생시켜야 한다.
Liskov and Snyder (1979)는 소프트웨어 공학적 근거에서 이러한 예외 접근을 정당화한다:
함수가 시그널하는 예외는 호출자에게 알려주는 것이 적절하다(그리고 이는 해당 함수가 구현하는 추상의 일부이다). 반면, 피호출자는(호출된 함수는) 호출된 함수의 구현에서 사용된 다른 함수들이 시그널하는 예외에 대해 아무것도 알아서는 안 된다.
CLU의 예외 접근은 또한 Fortran 77 스타일의 다중 반환 지점 구현을 지원한다. 선언된 각 예외에 대해, 호출자는 해당 예외의 핸들러에 대한 코드 레이블을 추가 인자로 전달한다. 예를 들어, 위의 sign 함수는 x 외에 두 개의 추가 인자를 받게 되는데, 하나는 zero 핸들러의 레이블, 다른 하나는 neg 핸들러의 레이블이다.
CLU 컴파일러는 함수가 발생시킬 수 있는 모든 예외가 그 함수의 signals 절에 선언되었는지를 정적으로 검사하지 않는다. 대신, 함수가 signals 절에 선언되지 않은 예외를 발생시키면, 이 예외는 치명적 오류로 변환되어 프로그램 실행이 중단된다. (CLU의 이후 버전에서는 이 예외가 호출 스택을 따라 전파되는 특별한 비검사 failure 예외로 변환된다.) Liskov and Snyder (1979)가 설명하듯, 이는 “설계상 불가능한” 예외를 프로그래머가 선언하거나 처리하지 않아도 되게 해 준다. 그들은 다음 예를 든다:
if ~ stack$empty(s) then
...
x := stack$pop(s)
...
end
코드가 스택 s가 비어 있지 않음을 명시적으로 검사하므로, pop 연산은 “빈 스택” 예외를 발생시킬 수 없다. 현재 함수가 발생시킬 수 있는 예외 중 하나로 “빈 스택”을 선언하도록 프로그래머에게 강제하는 것은 역효과를 낼 것이다.
C++의 검사된 예외.구조화된 예외는 1990년 경 C++에 추가되었고, 핸들러가 발견될 때까지 호출 스택을 따라 예외가 전파되는 지금은 익숙한 모델을 사용했다. 함수와 메서드는 자신(또는 자신이 호출하는 함수와 메서드)이 발생시킬 수 있는 예외 집합을 선택적으로 선언할 수 있었다:
int f(int x) throw(my_exception, some_other_exception) {
...
if (x < 0) throw new my_exception();
...
}
throw 절이 없다는 것은 어떤 예외든 발생할 수 있음을 의미한다.
CLU와 마찬가지로, 예외 검사는 완전히 동적이다. throw 절에 대한 정적 검증은 없다. 함수에서 빠져나간 예외가 그 함수의 throw 절에 선언되어 있지 않으면, 이 예외는 치명적 오류(std::unexpected 라이브러리 함수 호출)로 변환된다.
int f(int x) throw(my_exception) {
if (x < 0) throw new my_exception()
return -x;
}
int g(int x) throw() {
return f(x);
}
이 코드는 컴파일 타임에 오류를 내지 않는다. 그러나 g(-1)을 실행하면 std::unexpected가 호출된다.
C++의 검사된 예외에 대한 실무 경험은 대체로 부정적이다. throw 선언은 프로그램의 안전성에 큰 도움을 주지 못하며(std::unexpected에서의 중단이 미처리 예외에서의 중단보다 본질적으로 나은 것도 아니고), 상당한 런타임 비용을 추가한다(빠져나가면 안 되는 예외를 검사하기 위해).
그 결과, 2011년 C++ 개정은 throw 절을 폐기하고, 어떤 예외도 발생시키지 않는 함수와 메서드를 표시하기 위한 단순화된 선언 noexcept를 도입했다. noexcept 강제도는 여전히 동적이지만, 런타임 비용이 더 낮고 전체 throw 절보다 최적화 이점이 크다. 마지막으로, C++ 2017은 throw 절을 완전히 제거하고 noexcept만 남겼다.
Java의 검사된 예외.1995년에 도입된 Java는 13.4절에서 설명하는 "타입과 효과 시스템" 접근을 따르는, 정적으로 검사되는 예외를 가진 최초의 주류 프로그래밍 언어였다.
예외를 발생시킬 수 있는 각 메서드는 throws 절을 가져야 하며, 해당 메서드나 그가 호출하는 메서드들에 의해 빠져나갈 수 있는 예외 클래스들을 나열한다. (특별한 경우로, RuntimeException과 Error 클래스에 속하는 예외는 비검사 예외로 간주되어 선언할 필요가 없다.) throws 절이 없다는 것은 검사된 예외가 메서드 밖으로 빠져나갈 수 없음을 의미한다.
Java 컴파일러는 메서드 M이 발생시키는 예외 또는 M이 호출하는 메서드들의 throws 절에 언급된 예외가 M 안에서 처리되거나 M의 throws 절에 선언되어 있는지를 검사한다. 다음을 보자:
void writeList() throws IOException {
PrintWriter out = new PrintWriter(new FileWriter(...));
...
out.close();
}
Java 표준 라이브러리에서 FileWriter 생성자와 메서드들이 IOException을 발생시킬 수 있다고 선언되어 있으므로, writeList 메서드는 자신의 throws 절에 IOException을 언급해야 한다. throws 절을 생략하면 컴파일 타임 오류가 난다. IOException의 상위 클래스인 Exception을 throws로 선언하는 것도 올바르지만 덜 정밀하다.
writeList 메서드에서 IOException이 밖으로 빠져나갈 수 있음을 선언하지 않으려면, 이 예외를 writeList 안에서 처리해야 한다:
void writeList() {
try {
PrintWriter out = new PrintWriter(new FileWriter(...));
...
out.close();
} catch (IOException e) {
logger.error("I/O error: " + e.toString());
return;
}
}
이 경우 throws 절은 필요 없고, 컴파일러는 검사된 예외가 writeList 밖으로 빠져나갈 수 없음을 안다.
정적으로 검사된 예외에 대한 경험.Java는 컴파일러가 강제하는 정적 예외 검사를 제공한 최초의 주류 언어다. 이는 유용한 안전 보장을 제공한다. 메인 진입점에 throws 주석 없이 컴파일되는 프로그램은(단, Error 또는 RuntimeException 계열의 비검사 예외가 아닌 한) 런타임에 미처리 예외로 인해 크래시하지 않는다. 그러나 Java 스타일의 검사된 예외는 아래에 설명하듯 프로그래밍 및 소프트웨어 공학적 문제도 야기한다. 아마 그 결과로, Java 이후에 도입된 대부분 언어는 다른 접근을 사용한다. C#, Scala(버전 3까지), Kotlin은 비검사 예외를 사용하고, Rust와 Go는 예외를 지원하지 않으며 반환값을 통해 오류를 보고한다.
예외 명세의 실무적 문제는 소프트웨어 추상화 계층을 통해 "새어 나가기" 쉽다는 점이다. 라이브러리 A의 메서드 m이 다른 라이브러리 B의 메서드 k를 호출하는 상황을 생각하자. 기본적으로, k가 발생시킬 수 있는 모든 예외는 m의 throws 절에 선언되어야 한다. 여기에는 표준 예외뿐 아니라 B에서 선언된 예외도 포함되며, 따라서 A의 메서드 타입에 나타나게 된다. 이것은 A의 API를 불안정하게 만든다. B의 예외 동작이 바뀌거나, B가 나중에 다른 라이브러리 C로 대체되면 A의 API에 있는 throws 절이 달라질 것이다. 보다 실용적으로는, 여러 큰 라이브러리를 조합하면 매우 긴 throws 절이 생기기도 한다.
대안적으로, A의 메서드 m이 B의 메서드 k 호출에서 발생할 수 있는 모든 예외를 포착한 뒤, A의 예외로 다시 발생시키거나 B의 예외를 최선으로 지역 처리할 수 있다. 두 접근 모두 m의 코드를 더 크고 덜 명확하게 만든다. 두 번째 접근—때로 "포켓몬 예외 처리(Pokémon exception handling)"라 불리는데, 구호 "모든 [예외를] 잡아라"에서 따옴—는 흔히 중요한 예외가 무시되거나 잘못 처리되는 결과를 낳는다.
마지막으로, 정적으로 검사되는 예외는 제네릭 클래스를 정의하고 재사용하기 어렵게 만들기도 한다. 제네릭 배열 정렬 함수를 생각해 보자:
interface MyComparator<A> { int compare(A x, A y); }
interface MySort<A> { void sort(A[] arr, MyComparator<A> cmp); }
throws 선언이 없으므로, sort 메서드는 예외를 발생시킬 수 있는 compare 메서드와 함께 사용할 수 없다. sort를 더 재사용 가능하게 만들려면, compare가 발생시킬 수 있는 예외 클래스 E에 대해 MyComparator와 MySort 인터페이스 모두를 매개변수화해야 한다:
interface MyComparator<A, E extends Throwable> {
int compare(A x, A y) throws E;
}
interface MySort<A, E extends Throwable> {
void sort_array(A[] arr, MyComparator<A,E> cmp) throws E;
}
새 버전은 사용이 더 번거롭고, 이전 버전의 완전한 대체물도 아니다. compare가 예외를 던지지 않는 흔한 경우를 쉽게 표현할 수 없기 때문이다. 예외 선언에 대한 다른 형태의 타입 다형성은 13.5절과 13.6절에서 설명한다.
함수와 메서드 타입에서 빠져나갈 수 있는 예외를 추적하는 것만이, 모든 예외가 처리되고 프로그램이 미처리 예외로 중단될 수 없음을 정적으로 보장하는 유일한 방법은 아니다. 또 다른 방법은, 해당 예외의 핸들러의(동적) 스코프 안에 있지 않다면 예외 발생을 금지하는 것이다. 이 대안적 접근은 실험 언어 Effekt에서 도입되었고(Brachthäuser et al., 2020), 현재 Scala 3에서 선택 사항으로 제공된다(Odersky et al., 2021).
이 대안적 접근에서, 예외 E를 발생시키려면 함수가 그렇게 할 수 있는 "능력(capability)"을 가져야 한다. 이 능력은 Scala에서 CanThrow[E] 타입의 특별한 불투명(opaque) 값이다.
이 특별한 값은 try 핸들러가 만들어낸다. 더 정확히, try e catch case E 구성은 CanThrow[E] 능력을 생성하고 식 e에서 사용 가능하게 만든다. 그런 다음 이 능력은 Scala의 또 다른 고급 기능인 "암시적 함수 인자(implicit function arguments)"를 사용하여, 예외 E가 발생하는 프로그램 지점으로 전파된다.
예외 능력과 암시적 인자. 다음 Scala 3 함수 선언을 보자:
def m(x : T) : U throws E
겉보기에는 Java 스타일의 예외 선언, 즉 “m은 예외 E를 발생시킬 수 있다”처럼 보인다. 그러나 실제 의미는 “m이 호출될 때, 예외 E를 발생시킬 수 있도록 CanThrow[E] 능력이 주어져야 한다”이다. 사실 위 선언은 다음의 문법 설탕(syntactic sugar)이다:
def m(x : T) (using CanThrow[E]): U
이는 m이 타입 T의 명시적 인자와 타입 CanThrow[E]의 암시적 인자를 가진다는 뜻이다. 함수 m은 타입 CanThrow[E]의 값이 존재하는 문맥에서만 호출될 수 있다. 이 값은 자동으로 m에 전달된다. (실제로 CanThrow는 "삭제된(erased) 클래스"로, 런타임 표현을 갖지 않는다. 런타임에는 암시적 인자 값이 m에 실제로 전달되지 않지만, 정적 타입 검사기가 각 호출 위치에서 암시적 인자가 제공될 수 있음을 보장한다.)
좀 더 큰 프로그램 조각을 살펴보자:
def m(x : T) : U throws E = ... throw E ...
def p(x : T) : V throws E = ... m(x) ...
def q(x : T) : W =
try p(x) catch case e : E => 0
여기서 m은 암시적 인자로 CanThrow[E] 능력을 받아 예외 E를 발생시키는 데 사용한다. 이 능력이 없으면, 즉 throws E 절이 없으면, m에서 타입 CanThrow[E] 값을 얻을 수 없어 throw E 식을 타입 검사기가 거부할 것이다.
함수 p는 m을 호출한다. 따라서 m에 전달할 CanThrow[E] 능력이 필요하며, 이는 throws E 절 덕분에 암시적 인자로 전달받는다.
마지막으로, q는 try…catch case e : E 구성의 본문에서 p를 호출한다. try는 CanThrow[E] 타입의 값을 암시적으로 생성하여 p(x)의 평가 동안 사용 가능하게 만든다. 이 값이 p에 암시적 인자로 전달되는 값이다. q의 선언에는 throws E 절이 필요 없다. CanThrow[E] 능력은 q의 호출자에게서 전달되는 것이 아니라 q 내부에서 생성되기 때문이다.
고차 함수와의 상호작용.위의 예에서 throws 절은 표면적으로 Java 스타일의 예외 선언과 정확히 같아 보인다. 단지 설명이 다를 뿐이며, 빠져나갈 수 있는 예외 집합 대신 (개념적으로) 함수에 전달되는 능력을 다룬다. 능력 기반 접근의 새로움과 흥미로움은 예외를 발생시킬 수도 있고 아닐 수도 있는 함수를 인자로 받는 고차 함수를 고려할 때 드러난다. 주어진 리스트의 각 원소에 주어진 함수를 적용하는 고차 함수 map을 생각해 보자:
class List[A]
def map[B](f: A => B): List[B]
map이나 그 인자 f에 어떤 throws 절도 붙어 있지 않다. Java의 예외 선언 모델에서는, 이는 map이 오직 순수 함수(예외를 발생시키지 않는 함수)에만 적용될 수 있고, map 자신도 결코 예외를 발생시키지 않음을 의미한다. Scala의 능력 모델에서, map의 이 선언은 map이 어떤 CanThrow 능력도 필요로 하지 않으며, 따라서 map은 스스로 예외를 발생시키지 않음을 의미한다. 그러나 map은 f가 예외를 발생시킬 필요가 있는 경우에도, map이 적용되는 지점에서 필요한 능력들이 사용 가능하다면 f를 인자로 받을 수 있다:
def m(x : T) : U throws E = ... throw E ...
def map_m(xs : list[T]) : list[U] throws E =
xs.map(m)
xs.map(m)에서 m은 map_m에 전달된 암시적 CanThrow[E] 인자에서 자신의 암시적 인자를 가져온다. 그런 다음 암시적 인자가 없는 단순 함수 타입 T => U로서 map에 전달될 수 있다.
능력의 탈출 문제. 능력 기반 접근이 안전하려면, CanThrow[E] 능력은 예외 E에 대한 핸들러의 동적 스코프 내에서만 사용 가능해야 한다. 그렇지 않으면, 어떤 문맥에서는 핸들러가 없는데도 예외 E가 발생하여 프로그램이 미처리 예외로 중단될 수 있다. 현재 Scala 3 구현에서는 CanThrow 능력이 일급 값이며, 이들을 함수 내부에 숨기는 등으로 try…catch 구성에서 생성된 능력이 밖으로 탈출하기 쉽다. 다음을 보자:
def m(x : T) : Int throws E = ... throw E ...
def escape(x : T) : Int =
(try () => m(x) catch case e : E => 0) ()
try 블록은 CanThrow[E] 능력을 포착(capture)하는 함수 () => m(x)를 반환한다. 이 함수를 ()에 적용하면 m이 호출되고, E를 발생시켜 프로그램이 중단될 수 있다.
이 문제를 해결하고 try 블록에서 생성된 능력이 탈출하는 것을 막기 위해, Brachthäuser et al. (2020)은 능력을 "이등 시민(second-class) 값"으로 취급하여 사용 방식을 제한한다. 안타깝게도, 함수도 이등 시민 값으로 취급해야 하므로 유용성이 제한된다. Boruch-Gruszecki et al. (2023)은 함수 타입을 풍부하게 하여 함수가 포착할 수 있는 능력을 추적하는, 보다 유연한 접근을 탐구한다.
프로그램에서 구조화된 예외 처리를 사용할지, 언제, 어떻게 사용할지는 여전히 열린 논쟁거리다. Stroustrup (2019)는 C++ 맥락에서 이러한 질문 중 다수를 요약한다. Monperrus et al. (2014)는 여러 인기 있는 Java 패키지에서 예외 처리가 어떻게 사용되는지 연구한다.