1P by GN⁺ 2시간전 | ★ favorite | 댓글 1개
  • Grace양방향 타입 검사 구현에서 리스트 첫 원소 타입을 전체 원소 타입처럼 쓰던 방식 때문에 port: 8080 필드가 제거되어 잘못된 평가 결과가 나왔음
  • 문제 예제는 { domain: "google.com" }{ domain: "localhost", port: 8080 } 리스트를 순회하며 기본 포트 443을 쓰는 코드였고, 기대값 [ "google.com:443", "localhost:8080" ] 대신 [ "google.com:443", "localhost:443" ]를 반환했음
  • 기존 리스트 추론은 첫 원소에서 List { domain: Text }를 추론한 뒤 나머지 원소를 그 타입에 맞춰 검사했고, 정교화(elaboration) 과정에서 상위 타입에 없는 port 필드가 제거됐음
  • 수정된 구현은 각 원소 타입을 모두 추론한 뒤 가장 구체적인 상위 타입을 계산하고, 각 원소를 그 타입에 맞춰 다시 검사해 빠진 Optional 값을 null 또는 some으로 채움
  • 수정 후 원래 리스트는 List { domain: Text, port: Optional Natural }로 추론되며, 첫 레코드의 portnull, 두 번째 레코드의 port: 8080some 8080으로 보존되어 기대 결과를 반환함

Grace의 리스트 타입 추론 버그

  • GraceComplete and Easy Bidirectional Typechecking for Higher-Rank Polymorphism에 기반한 양방향 타입 검사 시스템을 사용하며, 이 접근의 한계를 우회하던 구현들이 쌓이면서 이상한 버그로 이어짐
  • 문제가 된 프로그램은 authorities 리스트를 순회하면서 각 레코드의 domainport를 바인딩하고, port가 없으면 443을 기본값으로 쓰는 리스트 컴프리헨션 형태였음
let authorities = [ { domain: "google.com" }
                  , { domain: "localhost", port: 8080 }
                  ]

for { domain, port = 443 } of authorities

in  "${domain}:${show port}"
  • 기대 결과는 [ "google.com:443", "localhost:8080" ]였지만, 버그가 있는 버전은 [ "google.com:443", "localhost:443" ]를 반환해 두 번째 레코드의 port: 8080 필드를 완전히 무시했음
  • 문제는 평가기가 아니라 타입 검사기에서 발생했으며, 리스트 타입 추론과 타입 검사 중 수행되는 정교화(elaboration)가 함께 영향을 줌

양방향 타입 검사와 기존 리스트 추론 방식의 한계

  • Grace가 authorities 리스트에 대해 기대한 타입은 다음과 같았음
List { domain: Text, port: Optional Natural }
  • 이 타입은 각 레코드가 필수 domain: Text 필드를 갖고, port: Optional Natural 필드는 있을 수도 없을 수도 있음을 뜻함
  • 실제 기존 구현은 다음처럼 더 좁은 타입을 추론함
>>> :type [ { "domain": "google.com" }, { "domain": "localhost", "port": 8080 } ]
List { domain: Text }
  • 양방향 타입 검사는 기본적으로 두 가지 작업으로 나뉨
    • 표현식의 타입을 추론함
    • 표현식이 기대 타입에 맞는지 검사함
  • 이 두 작업만으로는 리스트 안의 여러 원소 타입을 결합해 리스트 전체의 원소 상위 타입을 쉽게 만들 수 없음
  • 기존 Grace 구현은 리스트 타입을 다음 방식으로 추론했음
    • 리스트 첫 번째 원소의 타입을 추론함
    • 나머지 모든 원소를 첫 번째 원소에서 추론한 타입에 맞는지 검사함
  • 첫 번째 원소 { domain: "google.com" }의 타입이 { domain: Text }이므로, 전체 리스트 원소 타입도 { domain: Text }가 됨
  • 다른 타입을 원하면 명시적 타입 주석을 추가해야 했지만, Grace가 다루려는 현실의 JSON은 스키마가 크고 복잡할 수 있어 전체 스키마를 거대한 타입 주석으로 쓰게 만들고 싶지 않았음

정교화가 평가 결과까지 바꾼 이유

  • Grace의 타입 검사기는 타입 오류만 잡는 것이 아니라, 타입 검사 과정에서 표현식을 조정하는 정교화(elaboration) 도 수행함
  • 하위 타입을 상위 타입에 맞춰 검사할 때 두 타입이 다르면, 타입 검사기가 명시적 강제 변환(coercion)을 삽입함
  • Grace 평가기는 내부적으로 모든 Optional 값을 null 또는 some x 태그가 붙은 값으로 표현함
  • Optional이 기대되는 위치에 태그 없는 값을 넣으면, Grace는 자동으로 some 태그를 삽입함
>>> [ some 1, 2 ]  # This would be a type mismatch without elaboration
[ some 1, some 2 ]
  • 첫 번째 원소의 타입이 Optional Natural로 추론되고 두 번째 원소가 태그 없는 Natural이면, 타입 검사기는 타입 불일치 오류 대신 some 태그를 삽입함
  • 레코드에서도 같은 일이 일어나며, Grace는 레코드 하위 타입을 지원하고 상위 타입에 맞도록 레코드를 강제 변환함
>>> { x: 1, y: true }: { x: Natural }
{ "x": 1 }
  • 레코드를 더 작은 레코드 타입으로 주석 처리하면, 타입 검사기는 이를 허용하면서 상위 타입에 없는 필드를 제거함
  • authorities 리스트만 평가해도 기존 구현에서는 다음처럼 port 필드가 제거됨
>>> [ { domain: "google.com" }, { domain: "localhost", port: 8080 } ]
[ { "domain": "google.com" }, { "domain": "localhost" } ]
  • 이 결과는 다음 흐름으로 발생함
    • 첫 번째 원소의 타입이 { domain: Text }로 추론됨
    • 두 번째 원소가 그 기대 타입에 맞춰 검사됨
    • 두 번째 원소는 그 타입과 맞지만 추가 필드 port를 가짐
    • 타입 검사기가 기대 타입에 맞추기 위해 port 필드를 제거함
  • 레코드 강제 변환 자체가 근본 원인은 아니며, 실제 원인은 리스트 타입 추론에서 첫 번째 원소 타입을 전체 리스트 타입처럼 취급한 방식임

해결책: 가장 구체적인 상위 타입 계산

  • Grace는 리스트 전체 타입을 올바르게 추론하기 위한 새 연산을 추가함
  • 새 연산은 두 타입의 가장 구체적인 상위 타입(most-specific supertype), 즉 최소 상한(least upper bound)을 계산함
  • CAB의 상위 타입이라는 것은 CA의 상위 타입이면서 B의 상위 타입이라는 뜻임
  • CAB의 가장 구체적인 상위 타입이라는 것은 CAB의 다른 모든 상위 타입의 하위 타입이라는 뜻임
  • 새 연산을 사용한 리스트 타입 추론은 다음 순서로 바뀜
    • 리스트 각 원소의 타입을 추론함
    • 모든 원소 타입의 가장 구체적인 상위 타입을 계산함
    • 각 원소가 그 가장 구체적인 상위 타입에 맞는지 검사함
    • 그 가장 구체적인 상위 타입을 리스트 전체의 원소 타입으로 반환함
  • 이 방식으로 다음 리스트는 올바른 타입을 추론함
>>> :type [ { x: 1 }, { y: true } ]
List { x: Optional Natural, y: Optional Bool }
  • 내부 흐름은 다음과 같음
    • { x: 1 }{ x: Natural } 타입으로 추론됨
    • { y: true }{ y: Bool } 타입으로 추론됨
    • 두 타입의 가장 구체적인 상위 타입은 { x: Optional Natural, y: Optional Bool }이 됨
    • 각 원소가 그 상위 타입에 맞춰 검사됨
  • 각 원소를 다시 상위 타입에 맞춰 검사하는 이유는, 빠진 somenull을 채우는 등 올바른 정교화를 적용하기 위해서임
>>> [ { x: 1 }, { y: true } ]
[ { "x": some 1, "y": null }, { "y": some true, "x": null } ]

수정 후 authorities의 타입과 평가 결과

  • 타입 검사 수정 후 원래 authorities 리스트는 기대한 타입을 추론함
>>> :type [ { domain: "google.com" }, { domain: "localhost", port: 8080 } ]
List { domain: Text, port: Optional Natural }
  • 정교화된 평가 결과도 port를 선택 필드로 보존함
>>> [ { domain: "google.com" }, { domain: "localhost", port: 8080 } ]
[ { "domain": "google.com", "port": null }
, { "domain": "localhost", "port": some 8080 }
]
  • 첫 번째 레코드의 없는 portnull로 채워지고, 두 번째 레코드의 port: 8080some 8080으로 보존됨
  • 원래 리스트 컴프리헨션 예제도 기대한 결과를 반환함
[ "google.com:443", "localhost:8080" ]

JSON 지원과 구현 복잡성의 선택

  • Grace가 양방향 타입 검사를 강하게 밀어붙이는 이유는, 이 방식이 복잡하더라도 현실의 JSON 타입을 추론할 만큼 강력한 타입 검사 프레임워크라고 봤기 때문임
  • Hindley-Milner 추론이나 그와 비슷한 더 단순한 타입 검사 프레임워크는 실제 JSON 데이터에서 볼 수 있는 형태를 다루기 어려움
  • Grace는 JSON 작업만을 위한 인체공학적 언어로 만들어진 것은 아니지만, JSON 지원을 무시하지도 않았음
  • Dhall 경험을 통해 사용자가 인체공학적인 JSON 상호운용성에 민감하다는 점을 반영했고, Grace의 문법과 타입 시스템은 구현 복잡성이 늘더라도 JSON 호환성을 고려하도록 설계됨

참고할 만한 관련 자료

  • Datatype unification using Monoids: Grace에서 가장 구체적인 상위 타입을 계산할 때 사용하는 알고리듬과 본질적으로 같은, 단순 데이터 타입 추론 알고리듬을 다룸
  • The appeal of bidirectional typechecking: 하위 타입을 어떤 형태로든 사용하는 언어를 구현하려는 경우 양방향 타입 검사를 배울 가치가 있는 이유를 다룸
  • Local Type Inference: 양방향 타입 검사의 선구적 논문 중 하나이며, 최소 상한과 내부 언어로의 표현식 정교화 같은 기법의 출처로 제시됨

부록: 레코드 강제 변환이 필요한 이유

  • 다음 Grace 표현식은 레코드 강제 변환이 필요한 이유를 보여주는 예시임
let f (input: { }) = input.x

in  f { x: 1 }
  • 이 표현식이 타입 검사된다면 f의 반환 타입이 무엇이어야 하는지가 문제가 됨
  • 반환 타입이 Natural이어서는 안 됨
let f (record: { }): Natural = record.x  # WRONG: type error

in  f { x: 1 }
  • f는 빈 레코드 { } 타입의 input을 받기 때문에 그 레코드에서 Natural 값을 추출할 수 없음
  • 호출 시 우연히 x 필드가 있는 레코드를 넘기더라도, 함수 f{ } 타입의 어떤 입력에도 동작해야 함
  • 타입 검사기가 선언된 입력 타입에 없는 필드 접근을 거부하는 것도 건전한 선택이지만, 그러면 실제 JSON 데이터를 다루는 데 필요한 기능을 잃게 됨
  • 원래 authorities 예제는 다음 표현식의 문법 설탕과 본질적으로 같음
let authorities = [ { domain: "google.com" }
                  , { domain: "localhost", port: 8080 }
                  ]

for authority of authorities

let default = fold{ some port: port, null: 443 }

in  "${authority.domain}:${show (default authority.port)}"
  • 없는 필드 접근을 거부하면, 잠재적으로 빠져 있을 수 있는 필드를 바인딩하거나 기본값으로 처리할 수 없음
  • 현실의 JSON 데이터를 잘 다루기 위한 설계 선택은 다음과 같음
    • 필드가 없으면 null을 반환함
    • 접근 타입을 forall (a: Type) . Optional a로 지정함
  • 이 타입은 null만 가질 수 있는 타입임
  • 이 방식을 택하면 상위 타입에 없는 필드를 레코드에서 반드시 제거해야 함
  • 추가 필드를 제거하지 않으면 다음 예시는 1 : forall (a: Type) . Optional a를 반환하게 됨
let f (input: { }) = input.x

in  f { x: 1 }
  • 이는 null만 가져야 하는 타입에 1이 들어가는 잘못된 타입의 표현식이 됨
  • 그런 잘못된 표현식은 평가기를 깨뜨릴 수도 있음
let f (input: { }) = input.x  # Inferred type: forall (a: Type). Optional a

let default (input: Optional Text) = fold{ some text: text, "" }

in  "${default (f { x: 1 })}!"  # Runtime error if `f` returns `1`
  • 실제 JSON 데이터를 다루는 Grace에서는 레코드를 상위 타입에 맞춰 명시적으로 강제 변환하는 것이 합리적인 동작이며, 실제 버그는 강제 변환이 아니라 List 타입 추론을 위한 기존 임시 해법에 있었음
Lobste.rs 의견들
  • Complete And Easy 논문을 실제로 동작하는 형태로 만든 건 축하할 일임. 양방향 타입 검사의 탐욕적 성질이 미묘한 문제를 만들 수 있는 좋은 예이기도 함
    비난은 아니고, 추론에는 늘 문제가 있으며 결국 어떤 문제를 감수할지 고르는 일에 가까움. 그래서 개인적으로 서브타이핑과 타입 강제 변환은 대체로 안티패턴에 가깝다고 봄
    데이터를 타입의 진실 공급원으로 삼으면 데이터가 잘못됐는지 검사하기 어려워지고, 예시처럼 유용한 오류 대신 잘못된 타입을 얻게 됨. 다만 Dhall 만든 사람이라 이쪽은 나보다 훨씬 잘 알 것 같음. 여러 데이터를 받아 스키마를 생성하는 아이디어 자체는 연구할 만하지만, 타입이 보통 서술적이기보다 규정적이라고 여겨진다는 점에서 이를 “타입 검사”라고 부를지는 잘 모르겠음

  • 왜 그냥 f를 거부하지 않는지 잘 이해가 안 됨. 그 필드를 절대 가질 수 없는 타입의 레코드에서 필드에 접근하고 있으니, 거의 확실히 프로그램 버그이고 타입 검사기가 알려줘야 하는 상황으로 보임
    authority 예시와 다른 점은 port의 타입이 빠진 게 아니라 Optional이라는 점임. 빠진 필드를 거부한다고 해서 선택 필드까지 거부해야 하는 건 아님. 이미 타입 기반으로 값을 강제 변환하고 있다면 { domain: "google.com" }{ domain: "google.com", port: null } 값으로 강제 변환할 수 있음

    • 짧게 말하면 f를 굳이 거부할 필요가 없고, 그건 불필요한 제한임. 잘못된 필드 접근이 null : forall (a : Type) . Optional a를 반환하는 쪽이, 잘못된 필드 접근을 거부하는 쪽보다 엄격히 더 낫다고 봄
      더 많은 유효한 프로그램을 허용하고, 잘못 타입 지정된 프로그램은 여전히 실패함. 예를 들어 접근한 값을 null 처리 없이 사용하려는 프로그램은 그대로 타입 오류가 남
  • 바로 떠오르는 생각은 행 타입(row types) 이 이걸 해결한다는 것임. 이미 고려했을 것 같음. 여기서는 행 타입이 서브타이핑과 상호작용하면서 무너지는 건가?

    • Grace에는 행 타입과 행 다형성이 있음. Grace에서는 행을 “fields”라고 부르지만 그 외에는 정확히 같음
      실제로 가장 구체적인 상위 타입 알고리즘도 행 타입을 고려함
      예를 들어 이런 식으로 작성하면:
      \record0 record1 ->
      
      let _ = record0.x
      
      let _ = record1.y
      
      in  [ record0, record1 ]  
      
      Grace는 이 표현식에 대해 다음 타입을 추론함:
      forall (a : Type) .  
      forall (b : Type) .  
      forall (c : Fields) .  
        { x: b, y: a, c } -> { y: a, x: b, c } -> List { x: b, y: a, c }  
      
  • 이건 Bidirectional Typing 논문 5.1.1절에서 말하는 탐욕적 인스턴스화 문제의 예시 아닌가?
    “원래 설정에서 이 탐욕적 방법이 인수 순서에 취약하다는 점은 꽤 불운했다. 하지만 다른 형태의 서브타이핑이 없는 술어적 고랭크 다형성 설정에서는 잘 동작할 수 있다. ‘tabby-first problem’은 발생할 수 없는데, 타입이 엄격히 더 작아지는 유일한 방법은 엄격히 더 다형적이 되는 것이고, 첫 번째 인수가 다형적이라면 𝛼를 다형 타입으로 인스턴스화해야 하므로 술어성을 위반하기 때문이다”