이펙트 시스템
이펙트 시스템 (Effect System)
Nim은 함수(프로시저)가 할 수 있는 일을 컴파일러가 추적하는 이펙트 시스템을 갖고 있어요. 대표적인 것이 "이 프로시저가 어떤 예외를 던질 수 있는가"를 추적하는 예외 추적(excetpion tracking)이고, 이걸 더 확장하면 사용자 정의 이펙트(tag)까지 추적할 수 있어요. 코드와 함께 raises, tags, forbids, noSideEffect, gcsafe 같은 프라그마를 쓰면, "이런 부수 효과는 여기서 일어나면 안 된다"는 약속을 컴파일 시점에 지키게 할 수 있죠.
참고: 이펙트 추적의 규칙은 Nim 컴파일러 버전 1.6부터 바뀌었어요.
본문
예외 추적 (Exception Tracking)
Nim은 예외 추적을 지원해요. raises 프라그마를 쓰면 프로시저/이터레이터/메서드/컨버터가 던질 수 있는 예외를 명시적으로 정의할 수 있고, 컴파일러가 이를 검증해요:
proc p(what: bool) {.raises: [IOError, OSError].} =
if what: raise newException(IOError, "IO")
else: raise newException(OSError, "OS")
빈 raises 목록(raises: [])은 어떤 예외도 던지면 안 된다는 뜻이에요:
proc p(): bool {.raises: [].} =
try:
unsafeCall()
result = true
except CatchableError:
result = false
raises 목록은 프로시저 타입에도 붙일 수 있어요. 이는 타입 호환성에 영향을 줘요:
type
Callback = proc (s: string) {.raises: [IOError].}
var
c: Callback
proc p(x: string) =
raise newException(OSError, "OS")
c = p # type error
루틴 p에 대해 컴파일러는 추론 규칙(inference rule)으로 던질 수 있는 예외의 집합을 결정하는데, 이 알고리즘은 p의 호출 그래프(call graph)를 따라 동작해요:
- 어떤 프로시저 타입
T를 통한 모든 간접 호출은,T에 명시적인raises목록이 없는 한system.Exception(예외 계층의 기반 타입)을 던질 것으로 가정하므로 어떤 예외든 던질 수 있다고 봐요. 다만 호출이f(...)형태인데f가 현재 분석 중인 루틴의 파라미터이고.effectsOf: f로 표시되어 있다면 이 호출은 무시돼요. 그 호출은 낙관적으로 아무 이펙트도 없다고 가정하죠. 이 경우를 보완하는 게 규칙 2예요. - 호출 안에서 어떤 프로시저 타입
e를 가진 표현식이 프로시저p의.effectsOf로 표시된 파라미터로 전달되면, 그 표현식은 간접적으로 호출된다고 가정해요. 그래서 그 표현식의raises목록이p의raises목록에 더해져요. - 본문을 알 수 없는(전방 선언 때문인) 프로시저
q에 대한 호출은,q에 명시적인raises목록이 없는 한system.Exception을 던질 것으로 가정해요.importc된 프로시저는 명시적으로 다르게 선언되지 않았다면.raises: []를 가진다고 가정해요. - 메서드
m에 대한 모든 호출은,m에 명시적인raises목록이 없는 한system.Exception을 던질 것으로 가정해요. - 그 외 모든 호출은 분석으로 정확한
raises목록을 결정할 수 있어요. raises목록을 결정할 때는p의raise문과try문도 함께 고려돼요.
system.Defect에서 상속받은 예외는 .raises: [] 예외 추적 메커니즘으로 추적되지 않아요. 이는 내장 연산들과 더 일관적이에요. 아래 코드는 유효해요:
proc mydiv(a, b): int {.raises: [].} =
a div b # can raise an DivByZeroDefect
아래 코드도 유효해요:
proc mydiv(a, b): int {.raises: [].} =
if b == 0: raise newException(DivByZeroDefect, "division by zero")
else: result = a div b
그 이유는 DivByZeroDefect가 Defect에서 상속받고, --panics:on에서는 Defect가 복구 불가능한 오류(unrecoverable error)가 되기 때문이에요. (언어 버전 1.4 이후.)
EffectsOf 어노테이션 (EffectsOf annotation)
예외 추적 추론 규칙 1~2(앞 절 참조)는 아래처럼 동작하도록 보장해요:
proc weDontRaiseButMaybeTheCallback(callback: proc()) {.raises: [], effectsOf: callback.} =
callback()
proc doRaise() {.raises: [IOError].} =
raise newException(IOError, "IO")
proc use() {.raises: [].} =
# doesn't compile! Can raise IOError!
weDontRaiseButMaybeTheCallback(doRaise)
예시에서 보듯 proc (...) 타입의 파라미터는 .effectsOf로 표시할 수 있어요. 이런 파라미터는 이펙트 다형성(effect polymorphism)을 가능하게 해요. weDontRaiseButMaybeTheCallback 프로시저는 callback이 던지는 예외를 던지는 것으로 취급되죠.
그래서 많은 경우 콜백 때문에 컴파일러의 이펙트 분석이 지나치게 보수적이지 않아요:
{.push warningAsError[Effect]: on.}
import std/algorithm
type
MyInt = distinct int
var toSort = @[MyInt 1, MyInt 2, MyInt 3]
proc cmpN(a, b: MyInt): int =
cmp(a.int, b.int)
proc harmless {.raises: [].} =
toSort.sort cmpN
proc cmpE(a, b: MyInt): int {.raises: [Exception].} =
cmp(a.int, b.int)
proc harmful {.raises: [].} =
# does not compile, `sort` can now raise Exception
toSort.sort cmpE
태그 추적 (Tag Tracking)
예외 추적은 Nim의 이펙트 시스템의 일부예요. 예외를 던지는 것도 하나의 이펙트죠. 다른 이펙트도 정의할 수 있어요. 사용자 정의 이펙트는 루틴을 *태그(tag)*로 표시하고 이 태그에 대해 검사를 수행하는 수단이에요:
type IO = object ## input/output effect
proc readLine(): string {.tags: [IO].} = discard
proc no_effects_please() {.tags: [].} =
# the compiler prevents this:
let x = readLine()
태그는 타입 이름이어야 해요. tags 목록은 raises 목록처럼 프로시저 타입에도 붙일 수 있고, 이는 타입 호환성에 영향을 줘요.
태그 추적의 추론은 예외 추적의 추론과 유사해요.
특정 이펙트를 금지시키는 방법도 있어요:
type IO = object ## input/output effect
proc readLine(): string {.tags: [IO].} = discard
proc echoLine(): void = discard
proc no_IO_please() {.forbids: [IO].} =
# this is OK because it didn't define any tag:
echoLine()
# the compiler prevents this:
let y = readLine()
forbids 프라그마는 금지된 이펙트의 목록을 정의해요. 어떤 문장이 그 이펙트 중 하나라도 호출하면 컴파일이 실패하죠. 금지 이펙트가 있는 프로시저 타입은 그런 목록이 없는 동등한 프로시저 타입의 **부분타입(subtype)**이에요:
type MyEffect = object
type ProcType1 = proc (i: int): void {.forbids: [MyEffect].}
type ProcType2 = proc (i: int): void
proc caller1(p: ProcType1): void = p(1)
proc caller2(p: ProcType2): void = p(1)
proc effectful(i: int): void {.tags: [MyEffect].} = echo $i
proc effectless(i: int): void {.forbids: [MyEffect].} = echo $i
proc toBeCalled1(i: int): void = effectful(i)
proc toBeCalled2(i: int): void = effectless(i)
## this will fail because toBeCalled1 uses MyEffect which was forbidden by ProcType1:
caller1(toBeCalled1)
## this is OK because both toBeCalled2 and ProcType1 have the same requirements:
caller1(toBeCalled2)
## these are OK because ProcType2 doesn't have any effect requirement:
caller2(toBeCalled1)
caller2(toBeCalled2)
ProcType2는 ProcType1의 부분타입이에요. tags 프라그마와 달리, 부모 문맥(금지 이펙트가 있는 함수를 호출하는 함수)은 금지 이펙트 목록을 상속받지 않아요.
부수 효과 (Side Effects)
noSideEffect 프라그마는 파라미터를 통해서만 부수 효과(side effect)를 가질 수 있는 프로시저/이터레이터를 표시할 때 써요. 즉 그 프로시저/이터레이터는 파라미터에서 도달할 수 있는 위치만 변경하고, 반환값도 파라미터에만 의존해요. 파라미터 중 어느 것도 var, ref, ptr, cstring, proc 타입이 아니라면 변경되는 위치는 없어요.
다시 말해, 루틴이 스레드로컬(threadlocal)이나 전역 변수에 접근하지 않고, 부수 효과가 있는 루틴도 호출하지 않는다면 그 루틴은 부수 효과가 없는 거예요.
컴파일러가 검증할 수 없는데도 프로시저/이터레이터를 부수 효과가 없다고 표시하는 것은 정적 오류(static error)예요.
특수한 의미 규칙으로, 내장 debugEcho는 부수 효과가 없는 척 하도록 되어 있어요. 그래서 noSideEffect로 표시된 루틴을 디버깅할 때 쓸 수 있죠.
func는 부수 효과가 없는 프로시저의 문법적 설탕(syntactic sugar)이에요:
func `+` (x, y: int): int
컴파일러의 부수 효과 분석을 무시하려면 {.noSideEffect.} cast 프라그마 블록을 쓸 수 있어요:
func f() =
{.cast(noSideEffect).}:
echo "test"
부수 효과는 보통 추론돼요. 부수 효과 추론은 예외 추적의 추론과 유사해요.
컴파일러가 부수 효과를 추론할 수 없는 경우(import된 함수 같은 경우)에는 sideEffect 프라그마로 직접 표시할 수 있어요.
GC 안전성 이펙트 (GC Safety Effect)
프로시저 p가 GC된 메모리(string, seq, ref 또는 클로저)를 담고 있는 어떤 전역 변수에도 직접 또는 GC에 안전하지 않은 프로시저 호출을 거쳐 간접적으로 접근하지 않을 때, 우리는 그 프로시저 p를 GC safe라고 불러요.
GC 안전성 속성은 보통 추론돼요. GC 안전성 추론은 예외 추적의 추론과 유사해요.
gcsafe 어노테이션은 프로시저를 gcsafe로 표시하는 데 쓸 수 있어요. 그렇지 않으면 이 속성은 컴파일러가 추론하죠. 참고로 noSideEffect는 gcsafe를 내포해요.
C에서 import된 루틴은 항상 gcsafe라고 가정해요.
컴파일러의 gcsafety 분석을 무시하려면 {.cast(gcsafe).} 프라그마 블록을 쓸 수 있어요:
var
someGlobal: string = "some string here"
perThread {.threadvar.}: string
proc setPerThread() =
{.cast(gcsafe).}:
deepCopy(perThread, someGlobal)
더 알아보기:
effects 프라그마 (Effects pragma)
effects 프라그마는 프로그래머가 이펙트 분석을 하도록 돕기 위해 설계됐어요. 이 문장은 컴파일러가 effects가 위치한 지점까지의 모든 추론된 이펙트를 출력하게 해요:
proc p(what: bool) =
if what:
raise newException(IOError, "IO")
{.effects.}
else:
raise newException(OSError, "OS")
컴파일러는 IOError가 던져질 수 있다는 힌트 메시지를 만들어요. OSError는 effects 프라그마가 나타난 분기에서 던질 수 없으므로 목록에 포함되지 않아요.