- 알제브라적 이펙트는 재개 가능한 예외처럼 제어 흐름을 잡아 처리하는 언어 기능으로, Ante의 핵심 기능이며 Koka, Effekt, Eff, Flix 같은 연구 언어에서도 중심적으로 쓰임
- 같은 메커니즘으로 제너레이터, 예외, async, 코루틴, 자동 미분을 라이브러리 수준에서 만들 수 있고, 효과 다형성 덕분에
map같은 함수도 효과 종류와 무관하게 한 번만 작성 가능함 - 데이터베이스 접근, 출력, 로깅, 상태 전달 같은 의존성 주입과 컨텍스트 전달을 효과로 바꾸면 테스트 mock, 출력 수집, 로그 필터링을 핸들러 교체로 처리할 수 있음
- 함수 시그니처에
can IO,can Print,can Fail같은 효과가 드러나면 순수성 보장, 기록/재생, 보안 감사에 유리하지만, 이미 허용된 효과는 의도치 않게 기존 핸들러로 전파될 수 있음 - 전통적인 약점은 효율성 우려였으나, 최근 언어들은 tail-resumptive 효과 최적화, evidence passing, 단일
resume제한, 핸들러 특수화로 비용을 줄이고 있음
알제브라적 이펙트의 기본 모델
- 알제브라적 이펙트는 effect handlers라고도 불리며, “재개 가능한 예외”라는 모델로 이해할 수 있음
- Ante 의사코드에서는 효과 함수를 선언하고, 함수 시그니처에 해당 효과를 사용할 수 있음을
can으로 표시함say_message: Unit -> Unit같은 효과 함수를 호출하면 효과를 “던지는” 형태가 됨- 호출 함수는
foo () can SayMessage처럼 해당 효과 사용 가능성을 시그니처에 드러냄
handle표현식은try/catch와 비슷하게 효과를 잡고,resume호출로 중단된 계산을 이어감say_message핸들러가print "Hello World!"를 실행한 뒤resume ()을 호출하면 원래 계산이 계속되어42를 반환함
- “algebraic”이라는 이름은 대부분 잔존적인 용어이며, 실제로는 effect handlers가 더 정확한 표현에 가깝지만 사용자에게 익숙한 이름으로 알제브라적 이펙트를 사용함
사용자 정의 제어 흐름
- 알제브라적 이펙트는 여러 언어 기능을 하나의 메커니즘으로 구현하게 해줌
- 제너레이터
- 예외
- async
- 코루틴
- 자동 미분
- 효과 다형성은 what color is your function 문제를 줄임
map (input: Vec a) (f: a -> b can e): Vec b can e는 입력 함수f가 어떤 효과e를 수행하든map도 같은 효과를 수행한다고 표현함- 같은
map을 stdout 출력, 비동기 함수 호출, 스트림 yield 등과 함께 사용할 수 있음 - 많은 효과 핸들러 언어는 효과 변수
e를 생략해 익숙한map (input: Vec a) (f: a -> b): Vec b형태로 쓸 수 있음
- 예외는 효과를 처리할 때
resume을 호출하지 않는 방식으로 구현 가능함Throw a효과의throw: a -> never_returns를 정의함- 0으로 나누는 경우
throw "error: Division by zero!"를 호출하고, 핸들러는 메시지를 출력한 뒤 계산을 재개하지 않음
- 제너레이터는
Yield a효과의yield: a -> Unit로 구현할 수 있음- 벡터 요소를 순회하며
yield elem을 호출함 filter핸들러는 yield된 값이 조건을 만족하면 다시yield x를 호출하고resume ()으로 다음 요소를 이어감my_for_each핸들러는 yield된 값마다 함수f를 실행하고resume ()으로 계속 진행함
- 벡터 요소를 순회하며
- 협력적 스케줄러도
yield: Unit -> Unit효과로 만들 수 있으며, 핸들러가 제어를 받아 다른 함수 실행으로 전환함- Effekt의 scheduler 예제가 이 패턴을 보여줌
- 여러 효과는 서로 잘 합성되며, 이 점이 다른 효과 추상화보다 사용성을 높이는 장점으로 다뤄짐
의존성 주입과 테스트 가능성
- 효과는 일반 비즈니스 애플리케이션에서도 의존성 주입에 사용할 수 있음
- 데이터베이스 객체를 함수 인자로 직접 넘기는 대신
Database효과를 정의할 수 있음- 기존 형태는
business_logic (db: Database) (x: I32)처럼 DB 객체를 인자로 받음 - 효과 기반 형태는
business_logic (x: I32) can Database가 되고, 내부에서query "..."를 호출함
- 기존 형태는
- 구체적인 데이터베이스 선택은 호출 스택 상위의 핸들러가 맡음
- 운영 DB를 다른 DB로 바꾸거나 테스트용 mock DB로 교체할 수 있음
mock_database핸들러는query메시지를 무시하고 항상DbResponse.Ok를 반환하도록resume할 수 있음
- 출력도 효과로 다루면 테스트 중 stdout을 직접 쓰지 않고 문자열로 수집 가능함
print_to_string핸들러는print msg호출을 잡아all_messages문자열에 줄바꿈과 함께 누적함output_messages는 실제 출력 없이 반환값1234와 메시지 문자열을 검증할 수 있음
- 로깅은
Log효과와LogLevel을 사용해 조건부 출력으로 바꿀 수 있음log_handler는 메시지의 레벨이 설정된 기준 이상이면print msg를 호출함foo () with log_handler Error는 에러 로그만 출력함
더 깔끔한 API와 컨텍스트 전달
- 알제브라적 이펙트는 프로그램이나 라이브러리 전반에 전달되는 Context 객체 패턴을 효과로 표현할 수 있음
Use a효과는 상태 효과로 볼 수 있으며get: Unit -> a,set: a -> Unit을 제공함state핸들러는 초기 상태를 보관하고,get에는 현재 컨텍스트를 반환하며,set에는 새 컨텍스트로 갱신함- 예시
state정의는 소유권 규칙을 무시하며, 실제 구현에서는Copy a제약이 필요할 수 있음
- 벡터 내부에 문자열을 저장하고 인덱스를 키처럼 넘기는 예시는 컨텍스트 전달 비용을 보여줌
- 효과를 쓰지 않으면
push_string,get_string,append_with_separator,example등이strings를 계속 인자로 받아야 함 - 효과 기반 구현에서는 원시 연산인
push_string,get_string이get/set을 호출하고, 상위 코드는strings를 직접 넘기지 않아도 됨
- 효과를 쓰지 않으면
- 이 방식은 내부 컨텍스트 전달을 라이브러리가 감싸는 경우에 잘 맞음
- 라이브러리 사용자는 컨텍스트 전달 방식의 내부 세부사항을 신경 쓰지 않아도 됨
- 특정 컨텍스트 타입에 묶이지 않으려면 필요한 함수들을 인터페이스로 추상화할 수 있음
전역 변수 대체와 직접 스타일
- 난수 생성이나 메모리 할당처럼 겉으로는 무상태처럼 보이지만 실제로는 상태가 필요한 API는 전역 변수 대신 효과로 표현할 수 있음
- 난수 생성 예시는
Prng객체를 프로그램 전반에 직접 전달해야 하는 부담을 보여줌- 전역
Prng는 편하지만 스레드 안전성이 필요해지는 등 전역 값의 단점이 생김 Random효과의random: Unit -> U8을 사용하면 사용자는 상위 호출 스택 어딘가에서 핸들러로 초기화만 명시하면 됨- 이후
/dev/urandom이나 다른 난수 원천으로 바꾸려면 핸들러만 교체하면 되고 호출 스택의 나머지 코드는 바꿀 필요가 없음
- 전역
- 메모리 할당도
Allocate효과로 표현할 수 있음allocate: (size: Usz) -> Alignment -> Ptr afree: Ptr a -> Unit- 대부분의 호출에는 전역 allocator를 쓰고, tight loop 안에서는 루프 본문에 핸들러를 추가해 arena allocator로 바꿀 수 있음
- 효과는 전용 값으로 감싼 결과를 넘기는 방식보다 직접 스타일을 가능하게 함
Maybe t를 쓰면and_then,map으로 성공 경로를 이어야 함- Rust의
?같은 문법 설탕은 좋은 경로에 집중하기 위한 장치임 - 효과 기반
get_line_from_stdin (): String can Fail, IO와parse (s: String): U32 can Fail은 일반 순차 코드처럼line = ...,x = ...,x * 2로 작성됨
- 실패 처리는 핸들러를 적용해 좋은 경로에서 벗어나는 방식으로 다룰 수 있음
get_line_from_stdin () with default "42"는Fail효과를 기본값으로 처리함
- 서로 다른 오류 타입도 효과 목록으로 자연스럽게 합성됨
LibraryA.foo (): U32 can Throw LibraryA.ErrorLibraryB.bar (): U32 can Throw LibraryB.Errormy_function은Throw LibraryA.Error,Throw LibraryB.Error,Throw MyError를 함께 선언할 수 있음- 반복이 길어지면
AllErrors = can Throw ...같은 타입 별칭을 만들 수 있음 - 같은
Throw String효과는 하나로 합쳐지며, 분리하고 싶다면MyError같은 래퍼 타입이 필요함
순수성, 재실행성, 보안 감사
- 대부분의 효과 핸들러 언어는 OCaml 정도를 제외하면 부작용이 발생할 수 있는 곳에 효과를 사용함
- Ante에서는
can Print,can IO처럼 표시하지 않으면 부작용을 사용할 수 없음 extern정의는 컴파일러가 검사할 수 없어 타입 정의를 신뢰해야 함- 디버그 모드에서만
IO효과를 수행해 릴리스 모드의 효과 안전성을 유지하는 방식은 계획된 기능임
- Ante에서는
- 어떤 함수들은 입력으로 순수 함수를 요구함
- 스레드를 생성할 때 생성된 스레드가 현재 스레드 소유의 핸들러로 호출할 수 없어야 함
spawn_all (functions: Vec (Unit -> a pure)): Vec a can IO는 순수 함수만 받아 모든 함수를 스레드로 실행하고 완료를 기다리는 형태임
- Software Transactional Memory(STM)는 순수 함수가 필요한 동시성 기법임
- 여러 함수를 동시에 실행하다가 트랜잭션 중 값이 다른 스레드에 의해 변경되면 해당 트랜잭션을 다시 시작함
- Effekt의 개념 증명 구현은 effekt-stm에 있음
- 순수성은
rr디버깅 유틸리티와 비슷한 기록/재생 가능성을 제공할 수 있음record와replay두 핸들러가main이 내보내는 최상위 효과, 보통IO, 를 처리함record는 효과 발생과 결과를 기록하고, 실제 처리를 위해 내장IO핸들러로 다시 올림replay는 실제IO를 수행하지 않고 효과 로그의 결과를 사용함- 디버그 빌드에서 기본으로 기록하면 결정적 디버깅을 얻을 수 있음
- 함수 시그니처의 효과 목록은 Capability Based Security와 유사하게 보안 감사에 도움이 됨
get_pi: Unit -> F64는 백그라운드에서 몰래IO를 하지 않는다고 알 수 있음- 라이브러리 업데이트 후
get_pi: Unit -> F64 can IO가 되면 호출하는 쪽 함수가 이미IO를 요구하지 않는 한 코드에서 오류를 받게 됨 - 최소 효과만 선언하는 편이 바람직하며, 예를 들어 전체
IO보다Print만 선언하는 식이 좋음 - 새 효과 추가는 semantic versioning을 깨는 변경으로 다뤄짐
- 관련 자료로 Capability Based Security와 Designing with Static Capabilities and Effects가 있음
한계와 구현 전략
- 효과 접근의 한계 중 하나는 의도치 않은 처리가 가능하다는 점임
- 어떤 함수가 새로
IO를 요구하게 되어도 호출 함수가 이미IO를 허용하면 오류가 나지 않을 수 있음 Fail효과도 마찬가지로, 이전에는 실패하지 않던 라이브러리 함수가 나중에Fail할 수 있게 되면 기존Fail핸들러로 전파될 수 있음- 이 동작은 상황에 따라 괜찮을 수 있지만, 기본값 제공처럼 별도 처리를 원했을 경우에는 의도와 다를 수 있음
- 어떤 함수가 새로
- 전통적인 주요 단점은 효율성 우려였지만, 최근 효과의 컴파일 출력은 크게 개선됨
- 많은 알제브라적 이펙트 언어는 tail-resumptive 효과를 일반 클로저 호출로 최적화함
- tail-resumptive 효과는 핸들러가 마지막에
resume을 호출하는 효과임 - 실제 효과의 대부분이 여기에 해당하고, 본문 예시 대부분도 이 범주에 들어감
- 예외는
resume을 전혀 호출하지 않기 때문에 예외적 사례로 분류됨
- tail-resumptive 효과는 핸들러가 마지막에
- 언어별 최적화 전략도 다름
- Koka는 evidence passing을 사용하고 효과를 핸들러까지 끌어올려 런타임 없이 C로 컴파일함
- Ante와 OCaml은
resume을 최대 한 번만 호출하도록 제한함- 이 제한은 비결정성 같은 일부 효과를 배제함
- 대신 리소스 처리를 단순화하고 segmented stacks 같은 방식으로 내부 continuation을 더 효율적으로 구현할 수 있음
- Effekt는 핸들러를 프로그램에서 완전히 특수화해 제거함
- 이 방식은 대부분의 함수를 second-class로 만드는 제한이 있음
- boxed 형태로 first-class 함수를 얻고 pay-as-you-go 방식으로 전환할 수 있음
- 관련 자료로 Effekt captures 문서와 논문이 있음