예외

예외 (Exceptions)

프로그램을 짜다 보면 데이터가 예상과 다르게 들어오거나, 네트워크 연결이 끊기거나, 파일이 없어졌다거나 하는 오류 상황을 만나게 돼요. Raku에서 예외는 바로 그런 오류 정보를 담고 있는 객체예요. 예외 객체가 저장하는 정보에는 오류 상황을 설명하는 사람이 읽을 수 있는 메시지, 오류가 발생한 지점의 백트레이스(backtrace) 같은 것들이 있어요.

출처: Raku 공식 문서 — Exceptions

본문

내장된 예외들은 전부 Exception에서 상속받아요. Exception은 백트레이스 저장과 백트레이스 출력기(printer)를 위한 인터페이스 같은 기본 동작을 제공해요.

타입이 붙은 예외 (Typed exceptions)

타입이 붙은 예외는 미리 정의된 여러 오류 상황 중 하나를 나타내려고 써요. 각 예외는 오류에 대한 정보를 담고 있고, 던져졌을 때 그런 종류의 오류를 처리하려는 코드에 잡힐 수 있어요. ExceptionFailure 객체로 감싸면 지연시킬 수 있어요. 즉 처리용으로는 생성하되 던지지는 않는 거죠.

예를 들어 어떤 객체에 .frobnicate를 실행하는 동안 필요한 경로 foo/bar를 쓸 수 없게 됐다면, X::IO::DoesNotExist 예외를 던질 수 있어요.

method frobnicate($path) {
    X::IO::DoesNotExist.new(:$path, :trying("frobnicate")).throw
      unless $path.IO.e;
    # do the actual frobnication
}
frobnicate("foo/bar");
# OUTPUT: «Failed to find 'foo/bar' while trying to do '.frobnicate'
#          in block <unit> at my-script.raku:1»

객체가 백트레이스에 뭐가 잘못됐는지에 대한 정보를 담아 준다는 점을 눈여겨보세요. 코드 사용자는 이제 문제를 훨씬 쉽게 찾아 고칠 수 있어요.

X::IO::DoesNotExist 객체에서 .throw 메서드를 호출하는 대신, 그 객체를 die의 인자로 쓰는 방법도 있어요.

die X::IO::DoesNotExist.new(:$path, :trying("frobnicate"));

즉석(ad hoc) 예외

어떤 Exception 타입이 아닌 인자에 .throw, die, fail을 호출하면, 그 인자는 X::AdHoc 객체로 감싸져서 호출에 쓰여요.

예를 들어 오류를 설명하는 문자열에 die를 호출할 수 있고, 그 문자열은 X::AdHoc 예외의 message가 돼요.

die "oops, something went wrong";
# OUTPUT: «oops, something went wrong in block <unit> at my-script.raku:1␤»

die는 오류 메시지를 표준 오류인 $*ERR로 출력한다는 점도 참고할 만해요.

X::AdHoc도 다른 예외와 똑같은 Exception 타입이지만, 기본 타입으로 쓰기 위한 거예요. 이미 특정 Exception 타입이 제공된 상황에서는 쓰지 말아야 해요. X::AdHoc 타입을 위한 특별한 catch 핸들러를 따로 두는 건 그다지 도움이 되지 않을 거예요.

예외 잡기 (Catching exceptions)

CATCH 블록을 제공하면 예외 상황을 처리할 수 있어요.

CATCH {
    when X::IO { $*ERR.say: "some kind of IO exception was caught!" }
}
X::IO::DoesNotExist.new(:$path, :trying("frobnicate")).throw

# OUTPUT: «some kind of IO exception was caught!»

여기서는 X::IO 타입의 예외가 발생하면 some kind of IO exception was caught!라는 메시지를 stderr로 보내라고 말하는 거예요. $*ERR.say가 하는 일이 바로 그거죠. 그 순간의 표준 오류 장치(보통은 콘솔)에 표시돼요.

매치 대상은 역할(role)이라는 점을 주의하세요. 사용자 정의 예외가 같은 방식으로 매치되게 하려면 그 역할을 구현해야 해요. 같은 네임스페이스에 존재한다고 해서 비슷해 보일 뿐, CATCH 블록에서 매치되지는 않아요.

CATCH 블록은 던져진 어떤 예외든 그 토픽 변수($_)에 넣어요. 그래서 when 블록 안에서 여러 종류의 예외를 잡아 처리할 수 있어요. 모든 예외를 처리하려면 default 문장을 쓰면 돼요. 다음 예제는 일반 백트레이스 출력기와 거의 같은 정보를 출력해요. 메서드들이 CATCH 블록 안에서 Exception을 담고 있는 $_에 적용되는 것에 주목하세요.

CATCH {
     default {
         $*ERR.say: .message;
         for .backtrace.reverse {
             next if .file.starts-with('SETTING::');
             next unless .subname;
             $*ERR.say: "  in block {.subname} at {.file} line {.line}";
         }
     }
}

이건 아주 흔한 패턴이지만, CATCH 블록에서 defaultwhen을 꼭 써야 하는 건 아니에요. 이를 써서 제어 흐름이 블록 끝에 도달하는 걸 막는 거예요. 블록 끝에 도달하면, 예외가 재개(resume)되지 않는 한 예외는 계속 던져지기 때문이에요. 이 동작은 로깅 목적으로 활용할 수 있어요.

# In the outermost block of a script...
my IO::Handle:D $log = open sprintf('logs/%d-%d.txt', $*INIT-INSTANT, $*PID), :a;
CATCH { $log.printf: "[%d] Died with %s: %s$?NL", now, .^name, .message }
END   { $log.close }

CATCH 블록의 의미는 그 어휘 스코프(lexical scope) 안에서 정의된 위치와 무관하게 그 스코프 전체에 적용돼요. 그래서 CATCH 블록은 적용되는 어휘 스코프의 시작 부분에 두는 걸 권장해요. 그래야 코드를 대충 읽는 사람도 뭔가 특별한 일이 일어나고 있다는 걸 바로 알 수 있으니까요.

예외 핸들러와 감싸는 블록

CATCH가 예외를 처리한 다음에는, 그 CATCH 블록을 감싸고 있는 블록이 종료돼요.

다시 말해, 예외가 성공적으로 처리되더라도 감싸는 블록의 나머지 코드는 절대 실행되지 않아요.

die "something went wrong ...";

CATCH {
    # will definitely catch all the exception
    default { .Str.say; }
}

say "This won't be said.";   # but this line will be never reached since
                             # the enclosing block will be exited immediately
# OUTPUT: «something went wrong ...␤»

이것과 비교해 보세요.

{

  CATCH {
      default { .Str.say; }
  }

  die "something went wrong ...";

}

say "Hi! I am at the outer block!"; # OUTPUT: «Hi! I am at the outer block!␤»

예외가 발생한 지점으로 제어를 되돌리는 방법은 예외 재개(Resuming of exceptions)를 참고하세요.

try 블록

try 블록은 use fatal 프래그마를 암묵적으로 켜고, 예외를 떨어뜨리는 암묵적인 CATCH 블록을 포함하는 일반 블록이에요. 그래서 예외를 그 안에 가둘(contain) 수 있어요. 잡힌 예외는 Exception 타입의 값을 담는 $! 변수에 저장돼요. (참고로 try 블록 직후에 Exception이 던져지지 않았다면 $!은 정의되지 않아요.)

다음 같은 일반 블록은 그냥 실패할 뿐이에요.

{
    my $x = +"a";
    say $x.^name;
} # OUTPUT: «Failure␤»

하지만 try 블록은 예외를 가두고 $! 변수에 넣어요.

try {
    my $x = +"a";
    say $x.^name;
}

if $! { say "Something failed!" } # OUTPUT: «Something failed!␤»
say $!.^name;                     # OUTPUT: «X::Str::Numeric␤»

그런 블록에서 던져진 예외는 암묵적이든 사용자가 제공한 것이든 CATCH 블록에 잡혀요. 사용자가 제공한 CATCH의 경우, 처리되지 않은 예외는 다시 던져져요. 예외를 처리하지 않기로 하면 그 블록 안에 가둬져요.

try {
    die "Tough luck";
    say "Not gonna happen";
}

try {
    fail "FUBAR";
}

위 두 try 블록 모두에서 예외는 블록 안에 가둬지지만, say 문장은 실행되지 않아요. 예외를 처리할 수도 있어요.

class E is Exception { method message() { "Just stop already!" } }

try {
    E.new.throw; # this will be local

    say "This won't be said.";
}

say "I'm alive!";

try {
    # NOTE: Raku allows you to place the CATCH block anywhere in the
    # scope of the enclosing block. Some prefer it at the top for
    # visibility, others at the end.
    CATCH {
        when X::AdHoc { .Str.say; .resume }
    }

    die "No, I expect you to DIE Mr. Bond!";

    say "I'm immortal.";

    E.new.throw;

    say "No, you don't!";
}

출력은 다음과 같아요.

I'm alive!
No, I expect you to DIE Mr. Bond!
I'm immortal.
Just stop already!
  in block <unit> at exception.raku line 21

이렇게 되는 이유는 CATCH 블록이 die 문장이 던진 X::AdHoc 예외만 처리하고 E 예외는 처리하지 않기 때문이에요. CATCH 블록이 없으면 앞에서 말한 대로 모든 예외가 가둬지고 떨어져요. resume은 예외가 던져진 바로 다음 지점에서 실행을 재개해요. 이 경우에는 die 문장 안이죠. 자세한 내용은 예외 재개 섹션을 참고하세요.

try 블록은 일반 블록이므로 마지막 문장을 반환값으로 사용해요. 그래서 우변(right-hand side)으로 쓸 수 있어요.

say try { +"99999" } // "oh no"; # OUTPUT: «99999␤»
say try { +"hello" } // "oh no"; # OUTPUT: «oh no␤»

try 블록은 표현식의 반환값을 돌려주고, 예외가 던져졌으면 Nil을 돌려주는 방식으로 else 블록을 간접 지원해요.

with try +"♥" {
    say "this is my number: $_"
} else {
    say "not my number!"
}
# OUTPUT: «not my number!␤»

try는 블록 대신 문장과 함께, 즉 문장 접두어(statement prefix)로도 쓸 수 있어요.

say try "some-filename.txt".IO.slurp // "sane default";
# OUTPUT: «sane default␤»

try가 실제로 하는 일은 use fatal 프래그마를 통해 스코프 안에서 일어난 예외를 즉시 던지게 하는 거예요. 그렇게 함으로써 CATCH 블록은 예외가 던져진 지점에서부터 호출되는데, 그 지점이 곧 스코프를 정의해요.

my $error-code = "333";
sub bad-sub {
    die "Something bad happened";
}
try {
    my $error-code = "111";
    bad-sub;

    CATCH {
        default {
            say "Error $error-code ", .^name, ': ',.Str
        }
    }
}
# OUTPUT: «Error 111 X::AdHoc: Something bad happened␤»

예외 던지기 (Throwing exceptions)

예외는 die 루틴과 Exception 객체의 .throw 메서드로 명시적으로 던질 수 있어요. 추가로 처리되지 않은 Failure 객체가 가비지 컬렉션될 때 던져질 수 있어요.

이 예제는 X::AdHoc 예외를 던지고, 잡은 다음 .resume 메서드를 호출해 예외 발생 지점부터 코드를 계속 진행하게 해요.

{
    X::AdHoc.new(:payload<foo>).throw;
    "OHAI".say;
    CATCH {
        when X::AdHoc { .resume }
    }
}

"OBAI".say;

# OUTPUT: «OHAI␤OBAI␤»

CATCH 블록이 던져진 예외와 매치되지 않으면, 예외의 payload는 백트레이스 출력 메커니즘으로 전달돼요.

{
    X::AdHoc.new(:payload<foo>).throw;
    "OHAI".say;
    CATCH {  }
}

"OBAI".say;

# OUTPUT: «foo
#          in block <unit> at my-script.raku:1»

다음 예제는 예외 지점에서 재개하지 않아요. 대신 감싸는 블록 뒤에서 계속돼요. 예외가 잡혔기 때문에 CATCH 블록 뒤에서 제어가 이어지는 거죠.

{
    X::AdHoc.new(:payload<foo>).throw;
    "OHAI".say;
    CATCH {
        when X::AdHoc { }
    }
}

"OBAI".say;

# OUTPUT: «OBAI␤»

throwdie의 메서드 형태로 볼 수 있어요. 다만 이 특별한 경우에는 루틴의 sub 형태와 method 형태가 이름이 다를 뿐이에요.

예외를 지연시키기: failure

즉석 예외에서 말했듯이, throwdie에 더해 fail을 예외에 호출해서 나중에 처리하게 할 수도 있어요. 예외를 호출자에게 돌려줘서 그쪽에서 처리하게 하고 싶을 때 흔히 하는 일이에요.

결과로 나온 Failure 객체가 싱크 컨텍스트(sink context, 즉 처리되지 않은 상태)로 반환되면 diethrow를 호출한 것과 같아요. 반대로 Boolean 컨텍스트로 반환되거나 정의됨(definedness)이 검사되면 '처리됨'으로 간주되어 던지지 않아요. 변수에 할당되면, 그 전에 처리되지 않았다면 변수가 가비지 컬렉션될 때까지 던지기가 지연돼요.

예를 들어 루트 레벨에 새 디렉터리를 만들 권한이 없다고 가정할 때, mkdir "/test"Failure를 반환해요.

mkdir "/test";              # Returns Failure into sink context, acts like die()
my $result = mkdir "/test"; # throws when garbage-collected unless handled by then
so mkdir "/test";           # Failure "handled", doesn't die
if mkdir "/test" {          # Failure also "handled", doesn't die
    ...                     # mkdir was successful
}
unless mkdir "/test" {
    ...                     # Here we can handle the Failure (for real)
}

예외 재개 (Resuming of exceptions)

예외는 제어 흐름을 방해해서, 그것을 던진 문장 다음에 오는 문장에서 흐름을 돌립니다. 사용자가 처리한 예외는 재개될 수 있고, 그럴 경우 제어 흐름은 예외를 던진 문장의 다음 문장으로 계속돼요. 그러려면 예외 객체에서 .resume 메서드를 호출하면 돼요.

CATCH { when X::AdHoc { .resume } }         # this is step 2

die "We leave control after this.";         # this is step 1

say "We have continued with control flow."; # this is step 3

재개는 예외를 일으킨 문장 바로 다음, 그리고 가장 안쪽 호출 프레임에서 일어나요.

sub bad-sub {
    die "Something bad happened";
    return "not returning";
}

{
    my $return = bad-sub;
    say "Returned $return";
    CATCH {
        default {
            say "Error ", .^name, ': ', .Str;
            $return = '0';
            .resume;
        }
    }
}
# OUTPUT:
# Error X::AdHoc: Something bad happened
# Returned not returning

이 경우 .resumedie 문장 바로 다음에 오는 return 문장으로 가요. $return에 대한 할당은 효과가 없다는 점을 주의하세요. CATCH 문장이 bad-sub 호출 안에서 일어나고 있고, return 문장이 그 안에서 not returning 값을 할당하기 때문이에요.

잡히지 않은 예외 (Uncaught exceptions)

예외가 던져졌는데 잡히지 않으면, 프로그램은 0이 아닌 상태 코드로 종료해요. 그리고 보통 표준 오류 스트림에 메시지를 출력해요. 이 메시지는 예외 객체의 gist 메서드를 호출해서 얻어요. 이걸 이용하면 메시지와 함께 백트레이스를 출력하는 기본 동작을 억제할 수 있어요.

class X::WithoutLineNumber is X::AdHoc {
    multi method gist(X::WithoutLineNumber:D:) {
        $.payload
    }
}
die X::WithoutLineNumber.new(payload => "message")

# prints "message\n" to $*ERR and exits, no backtrace

제어 예외 (Control exceptions)

제어 예외는 X::Control 역할을 하는 Exception을 던질 때 발생해요 (Rakudo 2019.03부터). 보통 특정 키워드에 의해 던져지고, 자동으로 또는 적절한 phaser에 의해 처리돼요. 처리되지 않은 제어 예외는 일반 예외로 변환돼요.

{ return; CATCH { default { $*ERR.say: .^name, ': ', .Str } } }
# OUTPUT: «X::ControlFlow::Return: Attempt to return outside of any Routine␤»
# was CX::Return