12장: 레코드
새 파일
없음.
변경된 파일
src/typecheck.rssrc/data.rssrc/lexer.rssrc/elaborate.rssrc/ast.rssrc/core.rssrc/desugar.rssrc/eval.rssrc/fixity.rssrc/format.rssrc/main.rssrc/parse.rssrc/rename.rssrc/renamed.rs
동기
생성자의 세 번째 인자는 무엇을 의미합니까? 작은 점이나 쌍이라면 아마 기억할 수 있을 것입니다. 정수 여섯 개쯤을 담은 설정값이라면 코드를 읽을 때마다 작은 기억력 시험이 됩니다. 그 필드들에 이름을 붙여 봅시다.
Fun의 레코드는 우리가 이미 가진 데이터 타입 위에 세워집니다. 필드를 읽을 때는 점 구문을 사용하고, 모든 레코드 타입은 생성자를 하나만 갖도록 요구하겠습니다. 이렇게 하면 전통적인 Haskell 선택자의 거북한 두 가지 성질을 피할 수 있습니다. 잘못된 생성자를 골랐다는 이유로 접근이 실패할 수 없으며, 필드 이름이 일반 함수 이름을 차지하지도 않습니다. 두 레코드 타입이 모두 x 필드를 가질 수 있습니다[Marlow (2010); Mitchell & Fletcher (n.d.)].
흥미로운 작업은 추론에 있습니다. p.x를 볼 때 우리는 p에 대해 얼마나 알고 있습니까? 표현식의 다른 곳에 있는 정보가 그 질문에 답하는 데 도움이 되도록 만들겠습니다.
현재의 언어
data Point = Point { x :: Int, y :: Int }
move p = p { x = p.x + 1 }
main = move (Point { y = 4, x = 3 })
생성 시에는 모든 필드를 정확히 한 번씩 제공해야 합니다. 생성식의 표현식들은 선언 순서와 다르더라도 작성된 순서대로 실행됩니다. 위치 기반 생성과 패턴도 여전히 사용할 수 있습니다. 레코드 패턴에서는 필드를 생략할 수 있으며, 생략된 필드는 와일드카드가 됩니다. Point { x = n }는 첫 번째 좌표만 바인딩합니다.
프로젝션과 갱신은 적용보다 더 강하게 결합합니다. 따라서 f p.x는 f (p.x)를 뜻하고, f p { x = 3 }는 f (p { x = 3 })를 뜻합니다. 섹션 (.x)는 람다의 축약 표기입니다. shape.origin.x와 같은 연쇄 접근은 프로젝션을 두 번 수행합니다. 갱신은 타입 인자를 포함하여 수신자의 완전한 타입을 그대로 보존합니다.
data Label = Label { x :: String }
coordinate :: Point -> Int
coordinate p = p.x
시그니처가 어느 x를 뜻하는지 식별해 줍니다. 괄호로 감싼 식 표기 (p :: Point).x도 사용할 수 있습니다. 어떤 필드를 선언한 레코드가 하나뿐이라면, 검사기는 표기 없이도 그 수신자 타입을 추론할 수 있습니다.
이론
필드가 동일한 두 선언이라도 서로 다른 타입을 기술합니다. 여기서 명목적(nominal)이라는 말은 바로 이런 뜻입니다. Point는 단지 둘 다 우연히 x와 y를 담고 있다는 이유만으로 다른 레코드가 되지 않습니다. 반면 구조적 시스템이나 행 다형성(row-polymorphic) 시스템이라면 함수가 필요한 필드를 가진 어떤 레코드든 받아들이게 할 수 있습니다(Pierce 2002). 우리는 필드 접근이 런타임 이전에 특정 생성자와 슬롯으로 해석될 수 있도록 명목적 설계를 택합니다.
프로젝션은 수신자 타입과 결과 타입을 잇는 요구 제약(wanted constraint)을 도입합니다.
Has는 내부적인 검사 의무입니다. 이것은 결코 타입 스킴으로 일반화되지 않습니다. 필드 이름이 유일하다면, 검사기는 새로운 매개변수를 사용하여 수신자를 해당 레코드 타입과 즉시 단일화합니다. 그렇지 않으면 수신자의 명목적 헤드가 밝혀질 때까지 기다립니다. 바인딩 그룹을 일반화하기 전에는 고정점에 도달할 때까지 해석을 반복합니다. 끝까지 남아 있는 필드 요구는 모두 E0245와 표기 힌트를 만들어 냅니다.
GHC의 record-dot 제안은 표면 구문에 대해 참고할 만한 선행 사례입니다. 그 제안의 HasField 메커니즘은 다른 형태의 오버로딩된 접근을 지원합니다. Fun 필드 솔버는 정교화 전에 항상 구체적인 명목적 레코드 하나를 선택합니다(Mitchell and Fletcher, n.d.).
구현
점 분류하기
이 토큰에서는 공백이 의미를 가집니다. f.g는 프로젝션을 뜻하지만 f . g는 일반 합성 연산자를 사용합니다. 후처리 패스는 트리비아 수집과 레이아웃 처리가 인접성을 흐리기 전에 원래 토큰의 스팬을 비교합니다.
#![allow(unused)]
fn main() {
/// Classify dot adjacency before trivia capture and layout can erase spacing.
fn projection_dots(
file: FileId,
tokens: &mut [Spanned],
errors: &mut Vec<Diagnostic>,
) {
for i in 1..tokens.len().saturating_sub(1) {
if !matches!(&tokens[i].1,Token::Operator(op) if op==".")
|| tokens[i].2 != tokens[i + 1].0
|| !matches!(tokens[i + 1].1, Token::Ident(_))
{
continue;
}
if tokens[i - 1].2 != tokens[i].0 {
continue;
}
if tokens[i - 1].1 == Token::Left {
tokens[i].1 = Token::ProjSection;
if !matches!(
tokens[i + 2..].iter().find(|(_, t, _)| !matches!(
t,
Token::LineComment(_) | Token::BlockComment(_)
)),
Some((_, Token::Right, _))
) {
errors.push(Diagnostic::error(
ErrorCode::ProjectionDot,
"a projection section must have the form (.field)",
Span::new(file, tokens[i].0, tokens[i].2),
));
}
} else if matches!(
tokens[i - 1].1,
Token::Ident(_)
| Token::Right
| Token::BracketClose
| Token::Close
| Token::Int(_)
| Token::Char(_)
| Token::String(_)
) {
tokens[i].1 = Token::Proj;
}
}
}
}
프로젝션 앞에 오는 토큰은 식별자, 리터럴, 닫는 구분자 중 하나여야 합니다. ( 바로 뒤에 오고 변수 식별자 바로 앞에 오는 점은 프로젝션 섹션 토큰입니다. 그 밖의 점은 연산자로 남습니다. 특히 같은 규칙에 따라 x .y는 일반 연산자 출현으로 남습니다.
E0004는 (.x y)처럼 섹션으로 인식되었지만 형식이 잘못된 경우를 진단합니다. 포매터는 점과 레이블 토큰을 붙여 두고, 여기에 붙은 주석은 그 토큰 열 바깥으로 옮깁니다. 공백만 바꾸는 재작성이 자칫 프로그램의 의미를 바꿀 수 있으므로 두 결정 모두 테스트합니다.
필드 메타데이터를 명목적으로 유지하기
각 레코드 생성자는 필드 타입과 함께 필드 레이블을 보관합니다. 데이터 타입 검증은 다른 생성자와 섞인 레코드(E0240)와 선언에서 반복된 레이블(E0104)을 거부합니다. 이름 붙은 생성은 누락된 필드, 중복된 필드, 알 수 없는 필드를 각각 E0241, E0242, E0243으로 검사합니다.
레코드 패턴은 이름 변환 과정에서 위치 기반 생성자 패턴으로 바뀝니다. 리네이머는 각 레이블의 슬롯을 찾고, 생략된 모든 슬롯을 와일드카드로 채웁니다. 따라서 패턴 검사기와 Core 린트에는 새로운 레코드 패턴 규칙이 필요하지 않습니다. 값 환경에는 어떤 필드도 추가되지 않습니다.
필드 의무 풀기
wanted는 수신자 타입, 레이블, 새 필드 타입, 소스 위치, 추론 레벨을 기록합니다. 위치는 하나의 갱신 안에 있는 개별 필드를 구별합니다.
#![allow(unused)]
fn main() {
/// A field site's position distinguishes updates of several named slots.
pub type FieldSite = (u32, usize);
/// Field constraints never enter a polymorphic scheme.
#[derive(Clone)]
struct FieldWanted {
record: Type,
label: String,
field: Type,
site: FieldSite,
span: crate::source::Span,
level: u32,
}
}
프로젝션 추론 분기는 그 의무를 만들고 새 결과 타입을 반환합니다. 이후 해석이 성공하면 그 결과를 인스턴스화된 필드 선언과 단일화합니다:
#![allow(unused)]
fn main() {
Kind::Project(e, label) => {
let record = self.infer(env, e)?;
let field = self.fresh();
self.field(
record,
label.clone(),
field.clone(),
(expr.node, 0),
expr,
)?;
Ok(field)
}
}
solve_field는 생성자와 필드 슬롯을 식별하거나, 해당 필드가 없는 타입을 보고하거나(E0244), 알 수 없는 수신자를 미해결 상태로 남겨 둡니다. 해결된 필드 하나가 다른 필드의 수신자를 식별할 수 있으므로 이를 둘러싼 루프가 반복됩니다.
#![allow(unused)]
fn main() {
fn solve_fields(
&mut self,
complete: Option<u32>,
) -> Result<(), Diagnostic> {
loop {
let pending = std::mem::take(&mut self.wanted);
let count = pending.len();
for wanted in pending {
if !self.solve_field(&wanted)? {
self.wanted.push(wanted);
}
}
if self.wanted.len() == count {
break;
}
}
if let Some(level) = complete
&& let Some(w) = self.wanted.iter().find(|w| w.level > level)
{
let mut error = Diagnostic::error(
ErrorCode::AmbiguousField,
format!("ambiguous field access .{}", w.label),
w.span,
);
error.notes.push(
"add a type annotation to identify the record type".into(),
);
return Err(error);
}
Ok(())
}
}
시그니처 주도 검사는 람다 본문을 방문하기 전에 람다 매개변수 타입을 공급합니다. 이는 지역 바인딩이 공유 필드에 접근할 때 중요합니다. 바깥 시그니처는 그 지역 그룹이 일반화되기 전에 사용할 수 있어야 합니다. 기존의 경직된 시그니처 검사는 여전히 지나치게 많은 다형성을 주장하는 표기를 거부합니다.
엄격한 생성과 접근 정교화하기
소스에서의 필드 순서는 메모리에서의 필드 순서와 무관합니다. 따라서 생성은 제공된 각 식마다 엄격한 let을 도입한 뒤, 그 변수들을 선언 순서대로 사용해 생성자를 만듭니다.
#![allow(unused)]
fn main() {
Kind::Record(id, fields) => {
let declaration =
self.constructors[id.index as usize].clone();
let arguments = self.typed.instantiations[&expr.node]
.iter()
.map(|t| convert(t, variables))
.collect::<Vec<_>>();
let mut values = vec![Expr::Lit(Lit::Int(0)); fields.len()];
let mut locals = vec![];
for (label, e) in fields {
let index = declaration
.labels
.as_ref()
.unwrap()
.iter()
.position(|n| n == label)
.unwrap();
let parameter = Id {
name: self.sess.fresh_id("$field"),
ty: instantiate(
&declaration.fields[index],
&declaration.parameters,
&arguments,
),
};
locals.push(BindGroup {
recursive: false,
binds: vec![Bind {
name: parameter.clone(),
rhs: Box::new(self.expression(e, variables)),
}],
});
values[index] = Expr::Var(parameter);
}
let mut body = Expr::Con(id.clone(), arguments, values);
for local in locals.into_iter().rev() {
body = Expr::Let(local, Box::new(body));
}
body
}
}
프로젝션은 수신자를 한 번 평가하고, 그 유일한 생성자와 매치한 뒤, 선택된 필드 바인더를 반환합니다. 갱신은 같은 매치를 사용하고, 교체할 식을 소스 순서대로 평가하며, 바뀌지 않은 필드를 원래 슬롯에 둔 채 생성자를 다시 만듭니다. 결과 타입은 수신자 타입과 같으므로, 갱신으로 Box Int를 Box Bool로 바꿀 수는 없습니다.
Core는 11장에서 도입한 구문만 봅니다. 독립된 린트가 그 타입을 검사하고, 인터프리터는 평범한 매치와 생성자를 실행합니다. 값 프린터는 생성자의 레이블을 참조하여 Point {x = 3, y = 4}를 선언 순서대로 출력합니다. 이후 네이티브 프로젝션은 필드 로드가 될 것입니다.
실행해 보기
$ cd crates/ch12_records
$ cargo run -q -- run tests/programs/records/nested.fun --lint
(3,Outer {inner = P {x = 10, y = 4}})
$ cargo run -q -- dump --pass=types tests/programs/records/shared-signature.fun
main : Int
$ cargo test --test records
두 번째 레코드에 x 필드를 주고 프로젝션에서 표기를 제거해 보십시오. 모호성 오류를 보면 필드 이름만으로는 왜 항상 충분하지 않은지 알 수 있습니다. 또 하나의 유용한 실험은 수신자와 갱신되는 필드에 서로 다른 오류를 넣어 보는 것입니다. 최종 필드는 선언 순서대로 저장되지만, 첫 번째 오류는 소스의 평가 순서를 따라야 합니다.
연습 문제
- 입문. 다형적인
Box a레코드를 정의하고 그 프로젝션 섹션의 타입을 추론하십시오. - 중급. 같은 필드 레이블을 가진 레코드를 하나 더 추가하고, 모호성 오류를 관찰한 뒤, 타입 시그니처로 해결하십시오.
- 중급. 같은 바인딩 그룹 안에서 이름이 유일한 다른 필드가 앞서 해소되지 않은 접근의 모호성을 어떻게 해소할 수 있는지 설명하십시오.
- 고급. 소스의 필드 순서가 선언 순서와 다르고, 첫 번째 실패가 평가 순서를 드러내는 생성식을 작성하십시오.
- 고급. 행 다형적(row-polymorphic) 접근을 지원하려면 추론과 일반화의 어떤 부분을 바꿔야 하는지 찾아보십시오. 여기서 그 기능을 추가하지는 마십시오.
더 읽을거리
Haskell Report는 전통적인 레코드 생성, 갱신, 선택자 함수를 설명합니다 (Simon Marlow 2010). GHC 제안 282는 레코드 점 구문의 동기와 그것이 오버로딩된 필드 접근과 맺는 관계를 설명합니다 (Mitchell and Fletcher, n.d.). Pierce는 구조적 레코드 타입 체계를 전개하는데, 이는 Fun의 명목적 선택과 비교해 보기에 유용합니다 (Pierce 2002).