캡처 검사 기초

캡처 검사 기초 (Capture Checking Basics)

소개

캡처 검사(capture checking)는 언어 import로 켤 수 있어요.

출처: Scala 3 Reference

본문

import language.experimental.captureChecking

현재 캡처 검사는 여전히 매우 실험적이고 불안정하며 빠르게 진화해요. 시도해 보기 전에 최신 버전의 Scala를 사용하고 있는지 확인하세요.

캡처 검사가 무엇을 할 수 있는지 감을 잡기 위해 작은 예시부터 시작할게요.

import java.io.FileOutputStream
def usingLogFile[T](op: FileOutputStream => T): T =
  val logFile = FileOutputStream("log")
  val result = op(logFile)
  logFile.close()
  result

usingLogFile 메서드는 주어진 연산을 파라미터로 받은 새 로그 파일과 함께 호출해요. 연산이 끝나면 로그 파일은 닫히고 연산의 결과가 반환돼요. 이는 전형적인 try-with-resources 패턴으로, 다른 언어에서는 종종 특수 언어 구조로 지원되는 많은 유사한 패턴 중 하나예요.

문제는 usingLogFile의 구현이 완전히 안전하지 않다는 거예요. 종료 후 어떤 시점에 로깅을 수행하는 연산을 전달해서 이를 약화시킬 수 있어요. 예를 들어:

import java.io.FileOutputStream
def usingLogFile[T](op: FileOutputStream => T): T =
  val logFile = FileOutputStream("log")
  val result = op(logFile)
  logFile.close()
  result
val later = usingLogFile { file => () => file.write(0) }
later() // crash

later가 실행되면 이미 닫힌 파일에 쓰기를 시도하므로 잡히지 않는 IOException이 발생해요.

캡처 검사는 이런 오류를 정적으로 막을 수 있는 메커니즘을 제공해요. usingLogFile의 안전하지 않은 사용을 막기 위해, 이를 이렇게 선언할 수 있어요.

def usingLogFile[T](op: FileOutputStream^ => T): T =
  // same body as before

바뀐 유일한 것은 opFileOutputStream 파라미터 뒤에 ^가 붙었다는 거예요. 곧 알게 되겠지만, 이것은 파라미터를 수명이 추적되는 역량(capability)으로 바꿔요.

이제 문제의 값 later를 정의하려 하면 정적 오류를 받아요.

   |  val later = usingLogFile { f => () => f.write(0) }
   |                           ^^^^^^^^^^^^^^^^^^^^^^^^^
   |  Found:    (f: java.io.FileOutputStream^'s1) ->'s2 () ->{f} Unit
   |  Required: java.io.FileOutputStream^ => () ->'s3 Unit
   |
   |  Note that capability f cannot be included in outer capture set 's3.

이 경우 logFile 역량이 usingLogFile에 전달된 클로저에서 도피한다는 것을 쉽게 알 수 있었어요. 하지만 캡처 검사는 더 복잡한 경우에도 작동해요. 예를 들어 캡처 검사는 다음 안전한 코드와

import language.experimental.captureChecking
import caps.*
import java.io.FileOutputStream
def usingLogFile[T](op: FileOutputStream^ => T): T =
  val logFile = FileOutputStream("log")
  val result = op(logFile)
  logFile.close()
  result
val xs = usingLogFile { f =>
  List(1, 2, 3).map { x => f.write(x); x * x }
}

다음 안전하지 않은 코드를 구분할 수 있어요.

import language.experimental.captureChecking
import caps.*
import java.io.FileOutputStream
def usingLogFile[T](op: FileOutputStream^ => T): T =
  val logFile = FileOutputStream("log")
  val result = op(logFile)
  logFile.close()
  result
trait LzyList[+A]:
  def map[B](f: A => B): LzyList[B]^{this, f}

object LzyList:
  def apply[T](xs: T*): LzyList[T] = ???
val xs = usingLogFile { f => // error // error
  LzyList(1, 2, 3).map { x => f.write(x); x * x }
}

두 번째 경우에는 오류가 발생하지만 첫 번째 경우에는 발생하지 않아요. (이는 지연 계산 리스트의 캡처-인지 공식화인 LzyList를 가정한 것인데, 캡처 검사 클래스(chapter on capture checking classes) 장에서 나중에 소개할게요.)

캡처 검사는 매우 폭넓은 응용이 있다는 게 밝혀져요. 다양한 try-with-resources 패턴 외에도 프로그래밍 언어의 다른 오래된 문제들에 대한 해결책의 핵심 부분이 될 수 있어요. 그중에는:

  • 검사된 예외(checked exceptions)를 위한 간단하고 유연한 시스템을 가지는 방법. 캡처 검사가 Scala에서 깨끗하고 완전히 안전한 검사된 예외 시스템을 어떻게 가능하게 하는지는 나중에 보여줄게요.
  • 일반적으로 효과 다형성(effect polymorphism) 문제를 다루는 방법.
  • 동기적·비동기적 계산을 섞을 때 생기는 "당신의 함수는 무슨 색인가?" 문제를 해결하는 방법.
  • 지역 기반 할당(region-based allocation)을 안전하게 하는 방법.
  • 메모리 위치와 연관된 역량에 대해 추론하는 방법.

다음 절들은 캡처 검사가 Scala 3에서 어떻게 작동하는지 자세히 설명해요.

역량과 캡처 타입

캡처 검사 확장은 새로운 종류의 타입을 도입하고, 이 타입들을 다루기 위한 몇 가지 규칙을 강제해요.

캡처 검사는 T^{c₁, ..., cᵢ} 형태의 캡처 타입으로 수행돼요. 여기서 T는 타입이고, {c₁, ..., cᵢ}는 역량 c₁, ..., cᵢ에 대한 참조로 이루어진 캡처 집합(capture set)이에요.

객체 역량은 문법적으로 메서드 또는 클래스 파라미터, 지역 변수, 또는 감싸는 클래스의 this예요. 역량의 타입은 비어 있지 않은 캡처 집합을 가진 캡처 타입이어야 해요. 역량인 변수는 추적된다(tracked)고도 말해요.

어떤 의미에서 모든 역량은 자신이 캡처하는 어떤 더 포괄적인 다른 역량으로부터 권한을 얻어요. 재귀는 보편 역량(universal capability) any에서 멈추는데, 모든 다른 역량이 궁극적으로 여기서 파생돼요. T가 타입이라면 T^T^{any}의 약어로, T가 임의의 역량을 캡처할 수 있음을 뜻해요.

예시를 들어볼게요.

class FileSystem

class Logger(fs: FileSystem^):
  def log(s: String): Unit = ... // Write to a log file, using `fs`

def test(fs: FileSystem^) =
  val l: Logger^{fs} = Logger(fs)
  l.log("hello world!")
  val xs: LzyList[Int]^{l} =
    LzyList.from(1)
      .map { i =>
        l.log(s"computing elem # $i")
        i * i
      }
  xs

여기서 test 메서드는 FileSystem을 파라미터로 받아요. fs는 타입이 비어 있지 않은 캡처 집합을 가지므로 역량이에요. 이 역량은 Logger 생성자에 전달되어 클래스 Logger의 필드로 보존돼요. 따라서 지역 변수 lLogger^{fs} 타입을 가져요: 그것은 fs 역량을 보존하는 Logger예요.

test에서 정의된 두 번째 변수는 xs로, LzyList.from(1)에서 로깅과 매핑을 거쳐 얻은 지연 리스트예요. 리스트가 지연되므로 계산을 위해 로거 l에 대한 참조를 보존할 필요가 있어요. 따라서 리스트의 타입은 LzyList[Int]^{l}이에요. 반면 xs는 로깅만 하고 다른 파일 연산은 하지 않으므로 fs 역량을 간접적으로만 보존해요. 그게 fsxs의 캡처 집합에 나타나지 않는 이유예요.

캡처 타입에는 "더 작은" 캡처 집합을 가진 타입이 더 큰 집합을 가진 타입의 하위 타입이 되는 하위 타입 관계가 딸려 와요(하위 캡처 관계는 아래에서 더 자세히 정의해요). 타입 T가 캡처 집합을 가지지 않으면 순수(pure)하다고 부르며, T에 캡처 집합을 더한 어떤 캡처 타입의 하위 타입이 돼요.

함수 타입

평소의 함수 타입 A => B는 이제 임의의 역량을 캡처할 수 있는 함수를 나타내요. 우리는 그런 함수를 불순(impure)하다고 불러요. 반대로 새로운 단일 화살표 함수 타입 A -> B는 어떤 역량도 캡처할 수 없는, 다시 말하면 순수한 함수를 나타내요. 그 밖에는 순수한 함수의 화살표 뒤에 캡처 집합을 추가할 수 있어요. 예를 들어 A ->{c, d} B는 역량 cd는 캡처할 수 있지만 그 외 다른 것은 캡처할 수 없는 함수예요. 이 타입은 (A -> B)^{c, d}의 약어로, 즉 가능한 캡처가 {c, d}인 함수 타입 A -> B예요.

불순 함수 타입 A => BA ->{any} B의 별칭으로 취급돼요. 즉 불순 함수는 무엇이든 캡처할 수 있는 함수예요.

캡처 어노테이션 ^는 함수 화살표보다 더 단단하게 묶여요. 그래서 A -> B^{c}A -> (B^{c})로, A -> B^A -> (B^)로 읽어요.

유사한 관례가 컨텍스트 함수 타입에도 적용돼요. A ?=> B는 불순 컨텍스트 함수이고, A ?-> B가 그 순수 보완이에요.

참고 1: 식별자 ->?->는 이제 infix 타입 연산자로 쓰일 때 소프트 키워드로 취급돼요. 그것들은 여전히 항(term)을 위한 평범한 식별자로 사용 가능해요. 예를 들어 Map("x" -> 1, "y" -> 2) 매핑 문법은 항에만 적용되므로 여전히 지원돼요.

참고 2: 순수 vs 불순 함수 타입의 구분은 메서드에는 적용되지 않아요. 사실 메서드는 값이 아니므로 직접 아무것도 캡처하지 않아요. 메서드 안의 역량 참조는 대신 감싸는 객체의 캡처 집합에 포함돼요.

by-name 파라미터 타입

함수 타입과 유사한 관례는 by-name 파라미터로도 확장돼요.

def f(x: => Int): Int

실제 인자는 임의의 역량을 참조할 수 있어요. 그래서 다음은 괜찮아요.

f(if p(y) then throw Ex() else 1)

반면 f가 이렇게 정의되어 있다면

def f(x: -> Int): Int

f에 대한 실제 인자는 어떤 역량도 참조할 수 없으므로 위 호출은 거부돼요. 특정 역량을 허용할 수도 있는데 이렇게요.

def f(x: ->{c} Int): Int

여기서 f에 대한 실제 인자는 c 역량은 사용할 수 있지만 그 외 다른 것은 사용할 수 없어요.

지연 val (Lazy Vals)

지연 val은 캡처 검사 아래에서 파라미터 없는 메서드와 비슷하게 특별한 처리를 받아요. 지연 val은 두 개의 서로 다른 캡처 집합을 가져요.

  • 초기화자의 캡처 집합: 초기화 코드가 사용하는 역량들
  • 결과의 캡처 집합: 지연 val의 값이 캡처하는 역량들

초기화자 캡처

지연 val이 선언되면, 그 초기화자는 (메서드 본문처럼) 자신의 환경에서 검사돼요. 초기화자는 역량을 캡처할 수 있고, 이들은 따로 추적돼요.

import language.experimental.captureChecking
import caps.*
class Console extends SharedCapability:
  def println(msg: String): Unit = ()
def example(console: Console^) =
  lazy val x: () -> String =
    console.println("Computing x")  // console captured by initializer
    () => "Hello, World!"           // result doesn't capture console

  val fun: () ->{console} String = () => x()   // ok: accessing x uses console
  val fun2: () -> String = () => x()           // error: x captures console

여기서 x의 초기화자는 console을 사용하므로(메시지를 출력하기 위해), x에 처음 접근하면 console 역량을 사용할 거예요. 하지만 x의 결과는 어떤 역량도 캡처하지 않는 순수 함수 () -> String이에요.

타입 시스템은 결과 값이 역량을 갖지 않더라도 x에 접근하려면 console 역량이 필요하다는 것을 추적해요. 이것이 함수 타입에 반영돼요. funx에 접근하므로 캡처 집합에 {console}을 선언해야 해요.

지연 val 멤버 선택

한정자(qualifier)를 통해 지연 val 멤버에 접근할 때, 파라미터 없는 메서드를 호출하는 것처럼 한정자가 현재 캡처 집합에 부과돼요.

import language.experimental.captureChecking
import caps.*
trait Container:
  lazy val lazyMember: String =
    "value"

def client(c: Container^): Unit =
  val f1: () -> String = () => c.lazyMember        // error
  val f2: () ->{c} String = () => c.lazyMember     // ok

c.lazyMember에 접근하면 초기화가 촉발될 수 있고, 이는 c의 역량을 사용할 수 있어요. 따라서 캡처 집합은 c를 포함해야 해요.

메서드와의 동등성

캡처 검사 목적으로 지연 val은 파라미터 없는 메서드와 동일하게 동작해요.

import language.experimental.captureChecking
import caps.*
trait T:
  def methodMember: String
  lazy val lazyMember: String

def test(t: T^): Unit =
  // Both require {t} in the capture set
  val m: () ->{t} String = () => t.methodMember
  val l: () ->{t} String = () => t.lazyMember

이 동등성은 둘 다 감싸는 객체의 역량을 사용하는 계산을 촉발할 수 있다는 점을 반영해요.

하위 타입과 하위 캡처

캡처는 하위 타입에 영향을 줘요. 평소처럼 T₁ <: T₂은 타입 T₁이 타입 T₂의 하위 타입임을, 즉 T₁T₂에 부합함을 나타내요. 유사한 하위 캡처 관계가 캡처 집합에 적용돼요. C₁C₂가 캡처 집합이라면, C₁ <: C₂C₁C₂에 포함됨을, 또는 피연산자를 바꾸면 C₂C₁을 포함함을 나타내요.

하위 타입은 다음과 같이 캡처 타입으로 확장돼요.

  • 순수 타입은 캡처 타입의 하위 타입이에요. 즉 어떤 타입 T와 캡처 집합 C에 대해 T <: T ^ C.
  • 캡처 타입에 대해서는 더 작은 캡처 집합이 하위 타입을 만들어요. 즉 C₁ <: C₂이고 T₁ <: T₂라면 T₁ ^ C₁ <: T₂ ^ C₂.

하위 캡처 관계 C₁ <: C₂C₂C₁의 모든 원소 c를 반영할 때 성립해요. 이는 다음 세 가지 조건 중 하나가 참이어야 함을 뜻해요.

  • c ∈ C₂.
  • c가 어떤 클래스 Cls의 파라미터를 가리키고 C₂Cls.this를 포함한다.
  • c의 타입이 캡처 집합 C를 갖고 C₂C의 모든 원소를 반영한다(즉 C <: C₂).

예시 1. 다음이 주어졌을 때

fs: FileSystem^
ct: CanThrow[Exception]^
l : Logger^{fs}

우리는 다음을 얻어요.

{l}  <: {fs}     <: {any}
{fs} <: {fs, ct} <: {any}
{ct} <: {fs, ct} <: {any}

루트 역량 {any}로 이루어진 집합은 모든 다른 캡처 집합을 포함해요. 이는 궁극적으로 모든 역량이 any에서 생성된다는 사실의 결과예요.

예시 2. 앞선 FileSystem/Logger 예시를 다시 생각해볼게요. LzyList[Int]LzyList[Int]^{l}의 진짜 하위 타입이에요. 그래서 그 예시에서 test 메서드가 결과 타입 LzyList[Int]로 선언됐다면 타입 오류를 받게 돼요. 오류 메시지는 다음과 같아요.

11 |def test(using fs: FileSystem^): LzyList[Int] = {
   |                                                 ^
   |                                        Found:    LzyList[Int]^{fs}
   |                                        Required: LzyList[Int]

왜 반환된 값 xs의 타입인 LzyList[Int]^{l}이 아니라 LzyList[Int]^{fs}라고 말할까요? 이유는 ltest 본문 안의 지역 변수이므로, 그 본문 밖의 타입에서는 참조할 수 없기 때문이에요. 대신 타입은 l을 언급하지 않는 가장 작은 슈퍼타입으로 넓혀져요(widened). l이 캡처 집합 fs를 가지므로 {fs}{l}을 포함하고, {fs}test의 결과 타입에서 허용되므로, {fs}가 그 넓히기의 결과가 돼요. 이 넓히기를 회피(avoidance)라고 불러요. 캡처 검사만의 것은 아니고 Scala 타입의 모든 변수 참조에 적용돼요.

역량 클래스 (Capability Classes)

CanThrowFileSystem 같은 클래스는 그 값이 항상 역량으로 쓰이도록 의도된다는 속성을 가져요. 이 의도를 명시적으로 만들고 보일러플레이트를 줄이기 위해, 이 클래스들이 객체 caps에 정의된 SharedCapability 클래스를 상속하게 할 수 있어요.

SharedCapability를 상속하는 타입은 항상 캡처 집합을 갖고 와요. 캡처 집합이 명시적으로 주어지지 않으면 캡처 집합이 {any}라고 가정해요.

이는 FileSystemLogger 클래스를 다음과 같이 동등하게 표현할 수 있음을 뜻해요.

import caps.SharedCapability

class FileSystem extends SharedCapability

class Logger(using FileSystem):
  def log(s: String): Unit = ???

def test(using fs: FileSystem) =
  val l: Logger^{fs} = Logger()
  ...

이 버전에서 FileSystem은 역량 클래스이므로, Loggertest의 파라미터 타입에 나타나는 FileSystem 발생은 암시적으로 FileSystem^로 확장돼요. 반면 FileSystem^{f}FileSystem^{} 같은 타입은 적힌 대로 유지돼요.

Logger 예시의 마지막 버전에서 또 다른 무관한 변경은 FileSystem 역량이 이제 암시적 파라미터로 전달된다는 거예요. 여러 역량이 관여하게 되면 배선 오버헤드를 크게 줄여주므로 역량을 암시적 파라미터로 모델링하는 것은 아주 자연스러워요.

도피 검사 (Escape Checking)

역량은 평소의 스코프 규율을 따르는데, 이는 캡처 집합이 집합이 정의된 지점에서 보이는 역량들만 포함할 수 있음을 뜻해요.

이제 이 원칙이 소개 예시의 오류를 어떻게 만들었는지 재구성해볼게요. 거기서 usingLogFile은 이렇게 선언됐었어요.

def usingLogFile[T](op: FileOutputStream^ => T): T = ...

오류 메시지는 이랬어요.

   |  val later = usingLogFile { f => () => f.write(0) }
   |                           ^^^^^^^^^^^^^^^^^^^^^^^^^
   |  Found:    (f: java.io.FileOutputStream^'s1) ->'s2 () ->{f} Unit
   |  Required: java.io.FileOutputStream^ => () ->'s3 Unit
   |
   |  Note that capability f cannot be included in outer capture set 's3.

이 오류 메시지는 다음 로직으로 만들어졌어요.

  • f 파라미터는 FileOutputStream^ 타입을 가지므로 역량이 돼요.
  • 따라서 표현식 () => f.write(0)의 타입은 () ->{f} Unit이에요.
  • 그래서 usingLogFile에 전달된 전체 클로저의 타입은 의존 함수 타입 (f: FileOutputStream^'s1) ->'s2 () ->{f} Unit이 되는데, 여기서 's1's2는 아직 계산되지 않은 캡처 집합이에요.
  • 클로저의 기대 타입은 타입 변수 T의 어떤 인스턴스화에 대해 단순하고 파라메트릭하며 불순한 함수 타입 FileOutputStream^ => T예요.
  • 찾은 타입과 매칭하면 T는 값 later의 수준에서 정의된 어떤 캡처 집합 's3에 대해 () ->'s3 Unit 형태를 가져야 해요.
  • f는 지역적으로 바인딩되므로 그 캡처 집합은 역량 f를 포함할 수 없어요. 이것이 오류를 일으켜요.

유사한 제한이 변경 가능한 변수의 타입에도 적용돼요. 캡처 검사를 약화시키는 또 다른 방법은 지역 역량을 가진 클로저를 전역 변수에 할당하는 거예요. 아마 이렇게요.

import language.experimental.captureChecking
import caps.*
import java.io.FileOutputStream
def usingLogFile[T](op: FileOutputStream^ => T): T =
  val logFile = FileOutputStream("log")
  val result = op(logFile)
  logFile.close()
  result
var loophole: () => Unit = () => ()
usingLogFile { f =>
  loophole = () => f.write(0) // error
}
loophole()

이것도 컴파일되지 않아요. 변경 가능한 변수 loophole의 캡처 집합이 loophole이 정의된 곳에서 보이지 않는 변수 f를 참조할 수 없기 때문이에요.

단조성 규칙 (Monotonicity Rule)

객체 그래프를 보면 단조성 속성을 관찰할 수 있어요. 객체 x의 캡처 집합은 x를 통해 도달 가능한 모든 객체의 캡처 집합을 포함해요. 이 속성은 타입 시스템에 다음 단조성 규칙으로 반영돼요.

  • 필드 f가 있는 클래스 C에서 캡처 집합 {this}{this.f}뿐 아니라 this.f를 순수한 인자에 적용한 어떤 것의 캡처 집합도 포함해요.