19장: 클로저 변환
새 파일
src/closure.rssrc/flat.rssrc/flat_machine.rssrc/flat_lint.rs변경된 파일
src/lib.rssrc/main.rs
동기
함수는 자신을 만든 호출보다 오래 살아남을 수 있습니다. Fun에서는 이런 코드를 쉽게 작성할 수 있지만, 이 때문에 우리 백엔드에는 한 가지 질문이 남습니다. 그 지난 호출의 변수들은 이제 어디에 존재합니까?
우리 인터프리터는 각 함수와 함께 환경을 저장하는 방식으로 이 질문에 답했습니다. 우리는 같은 아이디어를 컴파일하는 프로그램 안에서 명시적으로 드러낼 것입니다. 각 람다는 프로그램 전체에 걸친 테이블의 함수가 됩니다. 그 클로저는 해당 함수가 바깥 스코프에서 필요로 하는 값들을 담고 다닙니다.
또한 이 함수들을 어떻게 호출할지도 알아볼 것입니다. 호출 대상을 알고 있을 때는 직접 호출할 수 있습니다. 함수가 인자로 전달되거나 다른 호출에서 반환되어 올 때는 그 클로저를 검사해야 합니다. 부분 적용은 흥미로운 경우를 하나 더 추가합니다. 네이티브 메모리에 객체를 배치하기 시작하기 전에, 우리는 이 모든 것을 Flat 인터프리터로 모델링할 것입니다.
현재의 언어
다음은 x를 기억하며 map에 전달할 수 있는 작은 함수입니다.
make x = \y -> x+y
main = print (map (make 20) [1,2,3])
반환된 함수를 호출할 때마다 전달된 값 20이 필요합니다. 여기서는 ANF 언커링(uncurrying)이 인접한 람다들을 하나의 전역 함수로 합치므로, 그 부분 적용은 해당 값을 PAP에 보관합니다. 오버로드된 덧셈에는 Num 사전도 필요합니다. 반면 바인딩이나 분기로 분리된 지역 람다는 명시적으로 캡처된 환경을 필요로 합니다. 부분 적용, 과포화 적용, 재귀 지역 함수, 그리고 값으로 사용되는 클래스 사전이 모두 계속 동작해야 합니다. 이제 --engine=flat이 기본값입니다. Core와 ANF도 여전히 선택할 수 있습니다.
클로저 환경은 컴파일러가 만드는 저장 공간입니다. 소스 언어에는 가변 필드나 참조 연산이 추가되지 않습니다. 여기서 새로 도입되는 유일한 변경 연산은 사용자 코드가 클로저를 관찰할 수 있기 전에 재귀 그룹의 캡처 슬롯을 초기화하는 것입니다.
이론
자유 변수 분석은 람다가 자신의 매개변수 외에 필요로 하는 값을 찾아냅니다. 전역 값은 프로그램이 직접 주소를 지정할 수 있으므로 지역 캡처 슬롯이 필요하지 않습니다. 남은 각 자유 변수에 대해 변환은 안정적인 슬롯을 선택하고, 클로저를 생성할 때 값을 저장하며, 끌어올려진 함수 안에서 렉시컬 접근을 환경 로드로 대체합니다.
클로저 변환과 람다 리프팅은 서로 관련이 있지만 다른 선택지입니다. 클로저 변환은 캡처한 값을 하나의 환경으로 묶습니다. 람다 리프팅은 그 대신 호출 지점이 허용하는 경우 그 값을 명시적인 함수 매개변수로 추가할 수 있습니다 (Appel 1992). 이 장에서는 지역 함수에 대해, 탈출하지 않는 함수까지 포함하여 하나의 환경 메커니즘만 유지합니다. 선택적 람다 리프팅은 뒤로 미룹니다. 알려진 지역 호출은 여전히 환경을 명시적으로 넘기면서 일반 apply를 우회할 수 있습니다.
소스 함수는 조금씩 나누어 호출될 수 있습니다. 우리가 끌어올린 기계 함수는 정해진 개수의 인자를 기대합니다. 이 두 관점을 어떻게 조화시킬 수 있겠습니까?
함수의 구문적 항수가 n인데 인자가 그보다 적으면, 우리는 인자를 부분 적용, 즉 PAP에 저장합니다. 정확히 n개를 가지고 있다면 함수에 진입합니다. 더 많이 가지고 있다면 n개로 함수를 호출한 다음, 남은 인자들을 그 함수가 반환한 함수에 적용합니다. 이것이 축소판 eval/apply 프로토콜입니다 (Marlow and Peyton Jones 2004). 이제 하나의 호출 메커니즘이 세 경우를 모두 처리합니다.
상호 재귀는 닭이 먼저냐 달걀이 먼저냐의 문제를 일으킵니다. even이 odd를 캡처하고 odd가 even을 캡처한다면, 어느 클로저를 먼저 만들 수 있겠습니까? 우리는 두 클로저를 자리 표시자 캡처 슬롯으로 할당한 다음, 두 주소가 모두 생기면 슬롯을 채울 것입니다. 이 초기화가 끝나기 전에는 어떤 소스 코드도 이 클로저들을 호출할 수 없습니다. 재귀 바인딩이 함수여야 한다고 요구하기 때문에, 완성되지 않은 재귀 값을 평가하려 하지 않고도 이것이 가능해집니다.
구현
Flat 문법
Flat은 ANF의 명시적인 제어 흐름을 유지하지만, 이제 함수는 별도의 함수 테이블에 존재합니다. 실행 가능한 본문 안에는 람다 형식이 없습니다. 대신 끌어올려진 함수를 가리키고 그 캡처를 저장하는 클로저를 할당합니다.
아톰 , join 이름 , 대안 태그 , 그리고 윗줄로 표시한 시퀀스에는 ANF의 표기법을 그대로 사용합니다. 이름 는 끌어올려진 함수를 식별합니다. 선택적인 환경 피연산자 는 아톰이거나 없습니다. 알려진 호출(known call)은 자신의 코드를 직접 지명합니다. 알려지지 않은 호출(unknown call)은 무엇을 할지 알아내기 위해 클로저나 PAP를 검사해야 합니다.
여기서 는 생성자, 는 원시 연산, 는 슬롯 인덱스입니다. 프리미티브의 소스 스팬은 생략했습니다. patch는 재귀 클로저를 서로 엮는 동안 쓰이는 내부 연산이며, Fun 프로그램이 사용할 수 있는 가변 상태가 아닙니다. ANF의 LetRec은 할당과 그 뒤를 잇는 이러한 패치들로 바뀌어 사라졌습니다.
끌어올려진 함수와 호출
Flat IR은 실행 가능한 본문에서 람다 표현식을 제거합니다. 각 끌어올려진 함수는 매개변수, 선택적인 환경 매개변수, 본문, 그리고 순서가 정해진 캡처 목록을 기록합니다.
#![allow(unused)]
fn main() {
pub use crate::anf::{Atom, Var};
use crate::{
ast::Op,
core::{self, AltCon},
data::ConId,
source::Span,
};
use std::rc::Rc;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
/// An index into this program's lifted function table.
pub struct FuncId(pub u32);
pub type Body = Rc<Flat>;
#[derive(Clone, Debug)]
/// Function globals are installed before ordered strict value initializers run.
pub struct GlobalInit {
pub name: Var,
pub function: Option<FuncId>,
pub body: Body,
}
#[derive(Clone, Debug)]
pub struct Program {
pub funcs: Vec<Func>,
pub globals: Vec<GlobalInit>,
pub metadata: core::Program,
}
#[derive(Clone, Debug)]
/// PatchClosure is a compiler-only operation used after all letrec allocations.
pub enum Rhs {
Atom(Atom),
CallKnown(FuncId, Option<Atom>, Vec<Atom>),
CallUnknown(Atom, Vec<Atom>),
AllocCon(ConId, Vec<Atom>),
AllocClosure(FuncId, Vec<Atom>),
EnvField(Var, u32),
Field(Atom, u32),
Prim(Op, Vec<Atom>, Span),
PatchClosure(Atom, u32, Atom),
}
#[derive(Clone, Debug)]
/// Control transfers are explicit and joins remain local to each function.
pub enum Flat {
Let(Var, Rhs, Body),
Case(Atom, Vec<(AltCon, Vec<Var>, Body)>),
Join(Var, Vec<Var>, Body, Body),
Jump(Var, Vec<Atom>),
Ret(Atom),
TailCallKnown(FuncId, Option<Atom>, Vec<Atom>),
TailCallUnknown(Atom, Vec<Atom>),
Fail(Span),
}
impl std::fmt::Display for FuncId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "f{}", self.0)
}
}
fn list<T: std::fmt::Display>(xs: &[T]) -> String {
xs.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ")
}
impl std::fmt::Display for Rhs {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Atom(a) => write!(f, "{a}"),
Self::CallKnown(id, env, args) => write!(
f,
"call {id}[{}]({})",
env.as_ref().map(ToString::to_string).unwrap_or_default(),
list(args)
),
Self::CallUnknown(a, args) => {
write!(f, "apply {a}({})", list(args))
}
Self::AllocCon(c, args) => {
write!(f, "{}({})", c.name, list(args))
}
Self::AllocClosure(id, args) => {
write!(f, "closure {id}[{}]", list(args))
}
Self::EnvField(v, i) => write!(f, "env {v}[{i}]"),
Self::Field(a, i) => write!(f, "field {a}[{i}]"),
Self::Prim(p, args, _) => {
write!(f, "{}({})", p.text(), list(args))
}
Self::PatchClosure(a, i, v) => {
write!(f, "patch {a}[{i}] = {v}")
}
}
}
}
impl std::fmt::Display for Flat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Let(v, r, b) => write!(f, "let {v} = {r};\n{b}"),
Self::Join(j, ps, b, r) => {
write!(f, "join {j}({}) = {{ {b} }};\n{r}", list(ps))
}
Self::Jump(j, args) => write!(f, "jump {j}({})", list(args)),
Self::Ret(a) => write!(f, "return {a}"),
Self::TailCallKnown(id, env, args) => write!(
f,
"tail {id}[{}]({})",
env.as_ref().map(ToString::to_string).unwrap_or_default(),
list(args)
),
Self::TailCallUnknown(a, args) => {
write!(f, "tail-apply {a}({})", list(args))
}
Self::Fail(s) => write!(f, "fail {s:?}"),
Self::Case(a, alts) => {
writeln!(f, "case {a} {{")?;
for (c, ps, b) in alts {
let head = match c {
AltCon::Con(c) => c.name.clone(),
AltCon::Lit(l) => l.to_string(),
AltCon::Default => "_".into(),
};
writeln!(f, "{head}({}) -> {{ {b} }}", list(ps))?;
}
write!(f, "}}")
}
}
}
}
#[derive(Clone, Debug)]
/// Only lifted local lambdas receive an environment parameter.
pub struct Func {
pub name: FuncId,
pub source: Var,
pub env: Option<Var>,
pub params: Vec<Var>,
pub body: Body,
pub captures: Vec<Var>,
}
}
closure::convert는 람다 정의를 수집하고, 자유 변수를 계산하며, 사용처를 분석합니다. 캡처 순서는 고유 이름 순서를 따르므로 덤프와 슬롯 인덱스가 결정적입니다. 최상위 함수 항목에는 환경 매개변수가 없습니다. 리프팅된 지역 항목에는 환경이 비어 있더라도 환경 매개변수가 있습니다. 각 지역 함수의 시작 부분에 있는 접두 EnvField 바인딩이 캡처를 불러옵니다.
호출은 인자 개수가 구문적 항수와 일치하고, 그 머리가 전역 함수이거나 탈출하지 않는 지역 람다일 때에만 알려진 호출입니다. 단지 함수 타입을 가졌다는 것만으로는 충분하지 않습니다. 실제 결정 과정은 직접 살펴볼 수 있을 만큼 작습니다.
#![allow(unused)]
fn main() {
fn known(&self, a: &Atom, n: usize) -> Option<(FuncId, Option<Atom>)> {
let (Atom::Var(v) | Atom::Global(v)) = a else {
return None;
};
let info = self.infos.get(v)?;
(n == info.lambda.params.len() && (info.global || !info.escapes))
.then(|| (info.id, (!info.global).then(|| a.clone())))
}
}
탈출 분석은 피호출자로서의 포화된 사용을 함수를 반환하거나, 저장하거나, 인자로 전달하는 것과 다르게 취급합니다. 알려진 지역 호출도 여전히 자신의 클로저를 환경 피연산자로 전달합니다. 그 밖의 호출은 CallUnknown을 사용합니다. 꼬리 위치에서는 그에 대응하는 꼬리 호출 형태가 선택됩니다. 따라서 부분 적용은 소스 표기상 익숙한 함수를 지명하더라도 계속 apply를 거칩니다.
우변 언어는 할당과 접근을 눈에 보이게 드러냅니다.
#![allow(unused)]
fn main() {
pub use crate::anf::{Atom, Var};
use crate::{
ast::Op,
core::{self, AltCon},
data::ConId,
source::Span,
};
use std::rc::Rc;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
/// An index into this program's lifted function table.
pub struct FuncId(pub u32);
pub type Body = Rc<Flat>;
#[derive(Clone, Debug)]
/// Only lifted local lambdas receive an environment parameter.
pub struct Func {
pub name: FuncId,
pub source: Var,
pub env: Option<Var>,
pub params: Vec<Var>,
pub body: Body,
pub captures: Vec<Var>,
}
#[derive(Clone, Debug)]
/// Function globals are installed before ordered strict value initializers run.
pub struct GlobalInit {
pub name: Var,
pub function: Option<FuncId>,
pub body: Body,
}
#[derive(Clone, Debug)]
pub struct Program {
pub funcs: Vec<Func>,
pub globals: Vec<GlobalInit>,
pub metadata: core::Program,
}
#[derive(Clone, Debug)]
/// Control transfers are explicit and joins remain local to each function.
pub enum Flat {
Let(Var, Rhs, Body),
Case(Atom, Vec<(AltCon, Vec<Var>, Body)>),
Join(Var, Vec<Var>, Body, Body),
Jump(Var, Vec<Atom>),
Ret(Atom),
TailCallKnown(FuncId, Option<Atom>, Vec<Atom>),
TailCallUnknown(Atom, Vec<Atom>),
Fail(Span),
}
impl std::fmt::Display for FuncId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "f{}", self.0)
}
}
fn list<T: std::fmt::Display>(xs: &[T]) -> String {
xs.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ")
}
impl std::fmt::Display for Rhs {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Atom(a) => write!(f, "{a}"),
Self::CallKnown(id, env, args) => write!(
f,
"call {id}[{}]({})",
env.as_ref().map(ToString::to_string).unwrap_or_default(),
list(args)
),
Self::CallUnknown(a, args) => {
write!(f, "apply {a}({})", list(args))
}
Self::AllocCon(c, args) => {
write!(f, "{}({})", c.name, list(args))
}
Self::AllocClosure(id, args) => {
write!(f, "closure {id}[{}]", list(args))
}
Self::EnvField(v, i) => write!(f, "env {v}[{i}]"),
Self::Field(a, i) => write!(f, "field {a}[{i}]"),
Self::Prim(p, args, _) => {
write!(f, "{}({})", p.text(), list(args))
}
Self::PatchClosure(a, i, v) => {
write!(f, "patch {a}[{i}] = {v}")
}
}
}
}
impl std::fmt::Display for Flat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Let(v, r, b) => write!(f, "let {v} = {r};\n{b}"),
Self::Join(j, ps, b, r) => {
write!(f, "join {j}({}) = {{ {b} }};\n{r}", list(ps))
}
Self::Jump(j, args) => write!(f, "jump {j}({})", list(args)),
Self::Ret(a) => write!(f, "return {a}"),
Self::TailCallKnown(id, env, args) => write!(
f,
"tail {id}[{}]({})",
env.as_ref().map(ToString::to_string).unwrap_or_default(),
list(args)
),
Self::TailCallUnknown(a, args) => {
write!(f, "tail-apply {a}({})", list(args))
}
Self::Fail(s) => write!(f, "fail {s:?}"),
Self::Case(a, alts) => {
writeln!(f, "case {a} {{")?;
for (c, ps, b) in alts {
let head = match c {
AltCon::Con(c) => c.name.clone(),
AltCon::Lit(l) => l.to_string(),
AltCon::Default => "_".into(),
};
writeln!(f, "{head}({}) -> {{ {b} }}", list(ps))?;
}
write!(f, "}}")
}
}
}
}
#[derive(Clone, Debug)]
/// PatchClosure is a compiler-only operation used after all letrec allocations.
pub enum Rhs {
Atom(Atom),
CallKnown(FuncId, Option<Atom>, Vec<Atom>),
CallUnknown(Atom, Vec<Atom>),
AllocCon(ConId, Vec<Atom>),
AllocClosure(FuncId, Vec<Atom>),
EnvField(Var, u32),
Field(Atom, u32),
Prim(Op, Vec<Atom>, Span),
PatchClosure(Atom, u32, Atom),
}
}
LetRec에 대해 변환은 모든 AllocClosure를 어떤 PatchClosure보다도 먼저 내보냅니다. IR에서 초기 캡처 슬롯에는 자리 표시자 정수 리터럴이 들어 있습니다. 패치한 뒤에는 각 슬롯에 실제로 캡처한 값이 들어갑니다. 패치 연산은 사용되지 않는 임시 바인더에 unit을 반환합니다. 모든 패치가 변환된 본문보다 앞서므로, 어떤 소스 표현식도 부분적으로만 초기화된 클로저를 호출할 수 없습니다.
Flat 린트는 각 끌어올려진 함수를 독립적으로 검사합니다. 린트는 암묵적인 자유 변수, 알 수 없는 함수 정체, 잘못된 알려진 호출 항수, 누락되었거나 예상치 못한 환경 피연산자, 범위를 벗어난 환경 로드, 잘못된 join 전달을 거부합니다. 각 환경 로드는 별도의 함수 스코프에서 이름을 다시 바인딩하므로, 캡처 바인더 식별자는 서로 다른 함수에서 반복해서 나타날 수 있습니다.
Flat 머신은 클로저 레코드를 담는 인덱스 기반 아레나를 소유합니다. 클로저 값은 아레나 인덱스입니다. 환경 슬롯에는 다른 클로저 인덱스를 포함한 일반적인 인터프리터 값이 들어 있습니다. 덕분에 참조 카운트 순환 없이 재귀적인 순환을 표현할 수 있습니다. PAP는 함수 값과 지금까지 공급된 인자를 저장합니다. 아레나는 한 번의 실행 동안 존재하며 객체 간의 관계를 모델링합니다. 메모리 회수는 아직 모델링하지 않습니다.
실행은 코드, 기지 호출, 적용(apply), 반환 상태를 따로 두는 반복 루프입니다. 알려진 호출은 진입점의 항수와 환경을 검증합니다. 적용은 PAP 인자를 평탄화하고, 인자가 부족한 호출과 정확한 호출을 처리하며, 남는 인자는 명시적인 컨티뉴에이션에 보관합니다. join은 함수 안에서 일어나는 어휘적 제어 전이로 남습니다. 기본 연산의 피연산자는 데이터 어댑터를 거치지만, 평가 과정에서 Flat 본문을 Core나 ANF 인터프리터에 위임하는 일은 결코 없습니다. 세 엔진이 제어 흐름을 각자 독립적으로 구현하기 때문에 결과가 일치한다는 사실에 의미가 있습니다.
실행해 보기
$ cd crates/ch19_closure
$ cargo run -q -- run tests/programs/closure/capture-return.fun --lint
[21,22,23]
$ cargo run -q -- dump --pass=types tests/programs/closure/capture-return.fun
main : IO ()
이 파일을 --pass=flat으로 덤프하여 make의 부분 적용을 찾아보십시오. 전역 진입점에는 환경이 없습니다. 적용은 사전(dictionary)과 정수 인자를 PAP에 저장합니다. 반면 escape-recursive.fun은 바깥 값을 캡처하는 지역 재귀 클로저를 반환하고, known-local.fun은 직접적인 지역 호출을 사용합니다. pap-chain.fun은 인자를 여러 단계에 걸쳐 공급합니다. over-application.fun은 결과 자체가 함수인 함수에 인자를 공급합니다. tests/anf.rs의 작은 Flat 스냅숏은 도달 가능한 코드만 골라내어 관련 없는 Prelude 함수를 생략합니다.
인자를 여러 단계에 걸쳐 공급해 보고, 지역 함수를 반환해 보고, 두 지역 함수가 모두 바깥 값을 캡처한 채로 서로를 호출하게 만들어 보십시오. 이 연습 문제들은 모두 소스에서는 자연스러워 보이지만, 각각 클로저 메커니즘의 서로 다른 부분을 시험합니다. Core, ANF, Flat의 결과는 일치해야 합니다. Flat 머신은 긴 꼬리 재귀 합계 계산이 대기 중인 컨티뉴에이션의 수를 일정한 한도 안으로 유지하는지도 검사합니다.
연습 문제
- 준비 운동. 언커링(uncurrying)과 부분 적용이 예제의 정수 인자와
Num사전을 어떻게 보존하는지 추적하십시오. - 중급. 포화 호출로만 쓰이던 지역 함수를 값으로 반환하도록 바꾸십시오. 그 결과 호출이 어떻게 분류되는지 설명하십시오.
- 중급. PAP가 인자를 하나 더 받은 뒤, 첫 번째 결과가 클로저인 과포화 호출이 이어지는 과정을 추적하십시오.
- 고급. 바깥의 정수도 캡처하는 상호 재귀 지역 함수를 구성하십시오. 패칭 후의 환경 그래프를 그리십시오.
- 고급. 탈출하지 않는 함수에 대한 람다 리프팅의 프로토타입을 만들고, 차등 일치를 유지하면서 할당 횟수를 비교하십시오.
더 읽을거리
- eval/apply, 부분 적용, 호출 규약 (Marlow and Peyton Jones 2004).
- 완전한 컴파일러에서의 클로저 변환 (Appel 1992).
- 엄격한 함수형 언어 구현과 값 표현 (Leroy 1990).
- 지역 조인 대 퍼스트 클래스(일급) 컨티뉴에이션 (Maurer et al. 2017).