모듈성 개선

모듈성 개선 (Modularity Improvements)

Martin Odersky, 7.1.2024

이 글은 Scala의 의존적 타이핑(dependent typing)에 대한 몇 가지 작은 변경을 제안해서, 모듈형 프로그래밍을 훨씬 더 직접적으로 만들 수 있게 해요. 제안된 개선은 future 소스 버전에서 이미 구현되어 있어요.

출처: Scala 3 Reference

본문

Martin Odersky, 7.1.2024

Scala는 SML 전통을 잇는 언어예요. 모듈(스칼라에서는 객체와 클래스 형태)의 멤버로 추상 타입과 별칭 타입을 가진다는 뜻이죠. 이는 단순한 의존적 타입 시스템으로 이어지는데, 타입의 의존성이 완전한 용어(term)가 아니라 경로(path)에 있게 돼요.

지금까지 몇 가지 핵심 요소가 빠져 있어서, Scala에서 펑터(functor)로 모듈을 합성하는 것이 SML보다 더 어려웠어요. 특히 자주 악명 높은 Aux 패턴에 의존해야 했죠. Aux 패턴은 타입 멤버를 타입 파라미터로 끌어올려서 클래스 인스턴스화를 가로질러 추적할 수 있게 하는 패턴이에요. 이 때문에 모듈형 의존적 타입 프로그램을 쓰고 읽는 게 훨씬 어려워지고, 그런 프로그래밍은 전문가만 접근할 수 있게 됐어요.

이 글에서 나는 Scala의 의존적 타이핑에 대한 몇 가지 작은 변경을 제안해요. 이 변경은 모듈형 프로그래밍을 훨씬 더 직접적으로 만들어 줘요.

제안된 개선들은 구현되어 있고, 추가적인 실험 언어 import modularity가 있을 때 future 소스 버전에서 사용할 수 있어요. 예를 들어 다음 명령을 쓰면 되죠:

scala compile -source:future -language:experimental.modularity

추적 파라미터 (Tracked Parameters)

Scala는 함수에 대해서는 의존적 타입을 지원하지만, 불행히도 클래스에 대해서는 그렇지 않아요. 예를 들어 다음 정의를 생각해 봐요:

class C:
    type T
    ...

  def f(x: C): x.T = ...

  val y: C { type T = Int }

그러면 f(y)Int 타입을 가지게 돼요. 컴파일러가 f의 결과 타입에서 형식 파라미터 x를 구체적인 파라미터 참조 y로 치환할 것이고, y.T = Int이니까요.

하지만 메서드 f 대신 클래스 F를 쓰면 일이 잘못돼요.

class F(val x: C):
    val result: x.T = ...

이제 F(y).resultInt 타입이 아니라, 그보다 덜 유용한 ?1.T 타입을 가지게 돼요. 여기서 ?1은 타입 C의 소위 스콜렘 상수(skolem constant)예요(스콜렘은 알 수 없는 값을 나타내요).

이 단점은 클래스가 의존적 타이핑에 의존하는 고급 모듈성 구조에 실제로 사용될 수 없다는 뜻이에요.

제안: 클래스나 트레이트의 val 파라미터에 추가할 수 있는 tracked 수식자를 도입해요. 클래스 C의 모든 tracked 클래스 파라미터에 대해, 클래스 멤버가 파라미터와 같다는 refinement를 C의 생성자 타입에 추가해요.

예시: 위 상황에서 F가 대신 이렇게 선언됐다고 가정해 봐요:

class F(tracked val x: C):
    val result: x.T = ...

그러면 생성자 F는 대략 다음 타입을 가지게 돼요:

F(x1: C): F { val x: x1.type }

참고: 더 정확히 말하면 파라미터와 refinement 모두 같은 이름 x에 적용되어야 하지만, refinement는 여전히 파라미터를 가리켜요. 안타깝게도 우리는 그것을 소스에서 표현할 수 없어서, 설명에서는 파라미터에 새 이름 x1을 선택했어요.

새 생성자 타입이 있으면 이제 표현식 F(y).result가 바라는 대로 Int 타입을 가지게 돼요. 그렇게 도달하는 추론은 다음과 같아요:

  • 생성자 F(y)의 결과는 의존 함수에 대한 표준 타이핑에 의해 타입 F { val x: y.type }을 가져요.
  • F 안의 result의 타입은 x.T예요.
  • 그러므로 F { val x: y.type }의 멤버로서 result의 타입은 y.T이고, 이는 Int와 같아요.

tracked 파라미터의 추가는 클래스를 의존적 타이핑을 지원하는 근본적인 모듈성 구조로 적합하게 만들어 줘요. issue #3920에서 가져온 예시가 있어요:

trait Ordering:
  type T
  def compare(t1:T, t2: T): Int

class SetFunctor(tracked val ord: Ordering):
  type Set = List[ord.T]

  def empty: Set = Nil

  extension (s: Set)
    def add(x: ord.T): Set = x :: remove(x)
    def remove(x: ord.T): Set = s.filter(e => ord.compare(x, e) != 0)
    def contains(x: ord.T): Boolean = s.exists(e => ord.compare(x, e) == 0)

object intOrdering extends Ordering:
  type T = Int
  def compare(t1: T, t2: T): Int = t1 - t2

val IntSet = new SetFunctor(intOrdering)

@main def Test =
  import IntSet.*
  val set = IntSet.empty.add(6).add(8).add(23)
  assert(!set.contains(7))
  assert(set.contains(8))

이제 이 코드는 제대로 동작해요. SetFunctor의 파라미터에 tracked를 추가하지 않으면, add 이후에 요소 타입 T의 추적을 타이프 체킹이 즉시 잃어버려서 실패하게 돼요.

문법 변경

ClsParam  ::=  {Annotation} [{Modifier | ‘tracked’} (‘val’ | ‘var’)] Param

tracked(소프트 수식자)는 클래스의 val 파라미터에만 허용돼요.

추적 추론 (Tracked Inference)

몇 가지 흔한 경우에는 tracked 수식자가 추론될 수 있어서 명시적으로 쓸 필요가 없어요. 특히, 형식 파라미터의 타입이 추상 타입 멤버를 정의하면 클래스의 val 파라미터에 대해 tracked를 추론해요. 이는 클래스 생성자에 전달된 실제 인자에서 그 멤버가 어떻게 정의됐는지에 대한 정보를 잃지 않는다는 뜻이에요.

예를 들어, 앞서 정의한 SetFunctor 클래스에 대해 tracked가 추론될 것이므로, 이렇게도 쓸 수 있어요:

class SetFunctor(val ord: Ordering):
  type Set = List[ord.T]
  ...

여기서 ord 파라미터의 tracked 수식자는 추론돼요. ord가 추상 타입 멤버 T를 정의하는 타입 Ordering이기 때문이에요.

또 다른 흔한 경우는 컨텍스트 바운드가 연관 타입(즉 추상 타입 멤버)을 가질 때예요. 예를 들어:

trait TC:
  type Self
  type T

class Klass[A: {TC as tc}]

여기서 tc는 연관 타입 T를 가진 컨텍스트 바운드예요. 그래서 tc에 대해 tracked val이 추론되고, 파라미터는 필드로 표현됩니다.

논의 (Discussion)

tracked가 그렇게 유용하다면 왜 기본으로 가정하지 않을까요? 첫째, trackedval 파라미터에만 의미가 있어요. 클래스 파라미터가 val로 선언된 필드가 아니라면 생성자 결과 타입에서 refinement할 것이 없어요. 적어도 모든 val 파라미터를 기본으로 tracked로 만드는 것을 생각해 볼 수 있지만, 그건 역호환되지 않는 변경이에요. 예를 들어 다음 코드는 깨질 거예요:

case class Foo(x: Int)
var foo = Foo(1)
if someCondition then foo = Foo(2)

파라미터 x(암시적으로 val)에 대해 tracked를 가정하면, fooFoo { val x: 1 } 타입으로 추론되어서, 다음 줄에서 Foo { val x: 2 } 타입의 값으로 재할당할 수 없게 돼요.

또 다른 우려는 모든 val 파라미터(케이스 클래스의 파라미터 포함)에 tracked를 쓰면 큰 refinement 타입이 생길 수 있다는 거예요.

그래서 추상 멤버를 정의하는 타입을 가진 파라미터에만 tracked를 추론하는 것이 쓸 만한 절충이에요. 결국, 이런 타입에 대해 tracked를 추론하지 않으면 경로를 통한 추상 타입 참조가 컴파일 오류를 낼 가능성이 높으니까요.

추적 멤버 (Tracked members)

tracked 수식자는 클래스와 트레이트의 val 멤버에도 쓸 수 있어서, 멤버(또는 그 오버라이딩 멤버)의 타입을 가능한 한 정확하게 강제해요. 더 정확히 말하면, tracked 멤버에 우변(rhs)의 추론된 타입을 할당할 거예요. 예를 들어 다음 정의를 생각해 봐요:

trait F:
  tracked val a: Int
  tracked val b: Int

class N extends F:
  val a = 22 // a.type =:= 22
  val b: Int = 22 // b.type =:= Int
  tracked val c = 22 // c.type =:= 22

여기 tracked 수식자는 N 안의 a의 타입이 22가 되도록 보장해요. Int가 아니라요. 하지만 b의 타입은 N에서 Int예요. 명시적으로 Int로 선언됐으니까요. tracked 멤버는 c의 경우처럼 즉시 초기화될 수도 있어요.

추적 문법 변경 (Tracked syntax change)

LocalModifier     ::=  ‘tracked’

tracked(소프트 수식자)는 로컬 수식자로 허용돼요.

적용된 생성자 타입 (Applied constructor types)

tracked 파라미터를 가진 클래스를 더 쉽게 쓰게 하기 위해 새 문법도 도입돼요. 새 문법은 본질적으로 클래스 생성자의 적용(application)을 타입으로 쓸 수 있는 능력이에요. 우리는 그런 타입을 **적용된 생성자 타입(applied constructor types)**이라고 불러요.

이 새 기능을 쓰면 다음 예시가 올바르게 컴파일되고, 주석의 타입이 적용된 생성자 타입의 결과 타입이에요.

import scala.language.experimental.modularity

class C(tracked val v: Any)

val c: C(42) /* C { val v: 42 } */ = C(42)

문법 변경 (Syntax change)

SimpleType        ::=  SimpleLiteral
                    |  ‘?’ TypeBounds
---                 |  SimpleType1
+++                 |  SimpleType1 {ParArgumentExprs}

SimpleType는 이제 선택적으로 ParArgumentExprs가 뒤따를 수 있어요.

그 인자들은 평범한 생성자 적용이었던 것처럼 전체 타입을 타입 체크하는 데 사용돼요. tracked 파라미터를 가진 클래스의 경우, 결과 타입이 각 tracked 파라미터에 대한 refinement를 가지게 된다는 뜻이에요.

예를 들어, 다음 클래스 정의가 주어졌을 때:

class Person(tracked val name: String, tracked val age: Int)

타입 Person("Kasia", 27)Person { val name: "Kasia"; val age: 27 }으로 번역됩니다.

클래스 부모가 refinement된 타입이 되도록 허용 (Allow Class Parents to be Refined Types)

tracked 파라미터가 생성자 타입에 refinement를 만들기 때문에, 이제 클래스가 refinement된 타입인 부모를 가질 수 있게 돼요. 이전에는 그런 타입이 허용되지 않았어요. 어떻게 다뤄야 할지 확신이 서지 않았으니까요. 하지만 tracked 파라미터가 있으면 그런 타입을 받아들이는 것이 절실해져요.

제안: refinement된 타입을 클래스의 부모 타입으로 허용해요. 이렇게 상속된 모든 refinement는 클래스의 합성 멤버(synthetic member)가 돼요.

예시

class C:
  type T
  def m(): T

type R = C:
  type T = Int
  def m(): 22

class D extends R:
  def next(): D

이 코드는 이제 컴파일돼요. D의 정의는 다음과 같이 확장됩니다:

class D extends C:
  def next(): D
  /*synthetic*/ type T = Int
  /*synthetic*/ def m(): 22

클래스 refinement가 D의 부모 생성자에서 D 클래스 자체의 본문으로 이동하는 것을 주목하세요.

이 변경은 문법 변경을 수반하지 않아요. 문법적으로 부모 타입은 그 자체로 refinement된 타입이 될 수 없어요. 그래서 다음은 불법이에요:

class D extends C { type T = Int; def m(): 22 }: // error
  def next(): D

refinement된 타입을 클래스의 부모 타입으로 직접 사용해야 한다면 괄호 안에 넣어야 해요:

class D extends (C { type T = Int; def m(): 22 }) // ok
  def next(): D

export 규칙의 작은 완화 (A Small Relaxation To Export Rules)

export 포워더(forwarder)에 대한 규칙이 다음과 같이 변경됩니다.

이전에는 모든 export 포워더가 final로 선언됐어요. 이제는 용어(term) 멤버만 final로 선언돼요. 타입 별칭은 제외돼요.

이는 같은 타입 멤버를 여러 트레이트로 export한 다음, 그 트레이트들을 같은 클래스에서 믹스인하는 것을 가능하게 만들어요. 테스트 파일 tests/pos/typeclass-aggregates.scala는, 타입 멤버를 가진 여러 given을 교차 타입(intersection type)으로 모두 집계하는 새 given으로 결합하려면 이것이 왜 필수적인지 보여줘요.

이 변경은 안전성을 잃지 않아요. 서로 다른 타입 별칭은 어차피 인스턴스화할 수 없는 클래스를 만들 거니까요.