추적된 역량 개요
추적된 역량 개요 (Tracked Capabilities Overview)
소개
추적된 역량(tracked capabilities)은 Scala의 가장 중요한 새 기능이에요. 이 페이지는 기저 개념의 개요를 제공하며, 역량이 무엇을 표현할 수 있고 그것으로 무엇을 할 수 있는지에 초점을 맞춰요.
본문
모든 세부사항에 대한 더 체계적인 설명은 다음 페이지들을 참고하세요.
- Capture Checking Basics: 캡처 검사 시작하기.
- Capture Checking of Classes: 캡처 검사 클래스와 객체를 위한 규칙.
- Capability Polymorphism: 캡처 타입과 캡처 집합 변수의 하위 타입.
- Scoping of Capabilities:
any와fresh역량의 스코프. - Capability Classifiers: 역량을 위한 분류자와
.only[...]연산자로의 투영. - Checked Exceptions:
CanThrow역량과throws절. - Stateful Capabilities: 변경 가능한 데이터 구조를 위한 역량.
- Separation Checking: 역량의 별칭과 공유를 제어하기 위한 더 상세한 검사.
- How to Use the Capture Checker: 캡처 검사를 켜고 구성하는 방법.
- Internals: 구현자를 위한 캡처 검사기 내부의 일부 설명.
추적된 역량은 Scala의 캡처 검사 확장 아래에서 지원되며, 언어 import로 켤 수 있어요.
import language.experimental.captureChecking
변경 가능한 데이터 구조와 별칭 제어와 관련된 캡처 검사의 몇 가지 기능은 현재 별도의 언어 import를 요구해요.
import language.experimental.separationChecking
두 확장 모두 실험적이므로, 세부사항이 여전히 바뀔 수 있어요. 캡처 검사는 현재 꽤 성숙했고 곧 안정화될 것으로 기대해요. 분리 검사는 아직 조금 더 유동적이에요.
역량 (Capabilities)
비공식적으로 역량은 "관심 있는" 값이에요. 예를 들어 파일 핸들, 접근 권한 토큰, 변경 가능한 데이터 구조 모두 역량으로 의미가 있어요. 하지만 쌍 ("hello", "world!")는 그냥 값이지 역량이 아니에요. 종종 역량은 효과와 연관돼요. 예를 들어 파일 핸들은 그것을 읽거나 쓰는 효과에 대한 접근을 주어요.
값의 타입이 표준 트레이트 Capability를 직접 또는 간접적으로 확장하게 만들면 그 값을 역량으로 지정할 수 있어요. 예를 들어 File은 이렇게 역량으로 선언될 수 있어요.
import language.experimental.captureChecking
import caps.*
class File(path: String) extends ExclusiveCapability
extension (file: File)
def close(): Unit = ()
def println(message: String): Unit = ()
여기서 ExclusiveCapability는 동시 접근을 막는 Capability의 하위 트레이트예요.
역량 추적 (Capability Tracking)
Scala 3의 역량은 추적돼요. 이것은 타입에, 그 타입의 값들이 어떤 역량에 접근할 수 있는지를 기록한다는 뜻이에요. 우리는 타입 A의 값들 중 역량 c에 접근할 수 있는 것을 A^{c}로 써요.
예를 들어 로그 메시지를 파일로 보내기 위한 클래스 Logger를 정의하고 이렇게 인스턴스화할 수 있어요.
import language.experimental.captureChecking
import caps.*
class File(path: String) extends ExclusiveCapability
extension (file: File)
def close(): Unit = ()
def println(message: String): Unit = ()
class Logger(file: File)
extension (logger: Logger^)
def log(message: String): Unit = ()
val out: File^ = new File("~/some/bits")
val lg: Logger^{out} = new Logger(out)
위 lg의 타입 Logger^{out}을 주목하세요. 그것은 lg가 클래스 Logger라는 것뿐 아니라 파일 out에 접근할 수 있다는 것도 나타내요. 우리는 또한 lg가 out을 캡처한다고 말하고 Logger^{out}을 캡처 타입(capturing type)이라 불러요.
일반적으로 타입 A^{c₁, ..., cₙ}은 역량 c₁, ..., cₙ을 보존하는 인스턴스를 나타내요. 클래스 A가 Capability를 확장하지 않는다면, 타입 A 단독은 역량을 보존하지 않는 인스턴스를 나타내요. 즉 A는 A^{}와 동등해요. 우리는 A가 순수(pure)하다고도 말해요. 순수 A의 반대는 임의의 역량을 보존할 수 있는 A의 인스턴스를 설명해요. 그 타입은 A^{any}, 또는 더 짧게 A^예요.
캡처 타입의 값들 자체가 역량으로 간주돼요. 예를 들어 위 lg는 클래스 Logger가 Capability를 확장하지 않는데도 역량으로 취급돼요.
역량 집합은 하위 타입 관계를 유도하는데, 더 작은 집합이 더 작은 타입으로 이어져요. 게다가 c가 캡처 타입 A^{c₁, ..., cₙ}이라면 {c} <: {c₁, ..., cₙ}도 성립해요. 이 경우 c가 기저 캡처 집합 {c₁, ..., cₙ}을 정제한다(refines)고 말해요.
예를 들어 out과 lg가 위에서 정의한 대로 역량이고 f가 어떤 다른 역량이라면, 우리는 다음을 가져요.
A <: A^{lg} <: A^{out} <: A^{out, f} <: A^
함수 타입 (Function Types)
함수 타입도 역량 집합을 갖출 수 있어요. 함수 타입 A -> B는 순수하다고 간주되므로 어떤 역량도 보존할 수 없어요. 그런 다음 다음 약어를 사용해요.
A ->{c₁, ..., cₙ} B = (A -> B)^{c₁, ..., cₙ}
A => B = A ->{any} B
함수는 그 본문이 접근하는 모든 역량을 캡처해요. 예를 들어 함수
import language.experimental.captureChecking
import caps.*
class File(path: String) extends ExclusiveCapability
extension (file: File)
def close(): Unit = ()
def println(message: String): Unit = ()
class Logger(file: File)
extension (logger: Logger^)
def log(message: String): Unit = ()
val out: File^ = new File("~/some/bits")
val lg: Logger^{out} = new Logger(out)
val next: Int ->{lg} Int = (x: Int) =>
lg.log(s"called with parameter $x")
x + 1
은 타입 Int ->{lg} Int를 가지는데, 이는 Int => Int의 하위 타입이에요.
Scala는 클래스와 객체의 멤버인 메서드를, 객체 자체인 함수와 체계적으로 구분해요. 메서드는 표현 가능한 타입을 가지지 않으므로 추적될 수 있는 역량 집합도 가지지 않아요. 대신 역량 집합은 감싸는 객체와 연관돼요. 예를 들어 다음에서
import language.experimental.captureChecking
import caps.*
class File(path: String) extends ExclusiveCapability
extension (file: File)
def close(): Unit = ()
def println(message: String): Unit = ()
class Logger(file: File)
extension (logger: Logger^)
def log(message: String): Unit = ()
val out: File^ = new File("~/some/bits")
val lg: Logger^{out} = new Logger(out)
val exec: Runnable^{lg} = new Runnable:
def run(): Unit = lg.log("called")
값 exec는 Runnable의 메서드 run이 lg에 접근하므로 타입 Runnable^{lg}를 가져요. 메서드는 파라미터를 전달하지 않고 이름만 대어 함수로 변환될 수 있어요(이것을 eta 확장이라 불러요). 예를 들어 값 exec.run은 타입 () ->{lg} Unit을 가질 거예요.
수명 (Lifetimes)
타입에서 역량을 추적하는 한 가지 결과는 그것들의 수명을 제어할 수 있다는 거예요. 예를 들어 새 파일에 대한 로거를 제공하면서 연산 op를 실행하는 함수가 있어요. 연산이 끝나면 파일은 닫히고 연산의 결과가 반환돼요. 이 함수는 제네릭이에요: 연산의 결과 타입은 필요할 때 인스턴스화될 수 있는 타입 파라미터 T예요.
import language.experimental.captureChecking
import caps.*
class File(path: String) extends ExclusiveCapability
extension (file: File)
def close(): Unit = ()
def println(message: String): Unit = ()
class Logger(file: File)
extension (logger: Logger^)
def log(message: String): Unit = ()
val out: File^ = new File("~/some/bits")
val lg: Logger^{out} = new Logger(out)
def logged[T](op: Logger^ => T): T =
val f = new File("logfile")
val l: Logger^{f} = new Logger(f)
val result = op(l)
f.close()
result
함수의 문제가 되는 사용은 연산의 결과에 로거 l을 누출시킬 거예요. 예를 들어 이렇게요.
import language.experimental.captureChecking
import caps.*
class File(path: String) extends ExclusiveCapability
extension (file: File)
def close(): Unit = ()
def println(message: String): Unit = ()
class Logger(file: File)
extension (logger: Logger^)
def log(message: String): Unit = ()
val out: File^ = new File("~/some/bits")
val lg: Logger^{out} = new Logger(out)
def logged[T](op: Logger^ => T): T =
val f = new File("logfile")
val l: Logger^{f} = new Logger(f)
val result = op(l)
f.close()
result
val bad = logged { l =>
() => l.log("too late!")
}
여기서 logged에 전달된 연산의 결과는 중첩 함수 () => l.log("too late!")이에요. 이것이 bad의 값이기도 해요. 따라서 호출 bad()는 l.log를 호출하는데, 이 시점에 로거 밑의 파일은 이미 logged가 닫았어요. 다행히 bad의 정의는 Scala 3의 타입 시스템에서 거부돼요. 본질적으로 logged 정의의 타입 파라미터 T는 op에 전달된 로거의 정체성과 독립적이어야 해요. 나쁜 사용 시나리오에서 op의 결과 타입은 () ->{l} Unit이므로 그 캡처 집합이 로거 파라미터에 의존하기 때문에 이 요구가 위반돼요.
수명의 세밀한 제어는 추적된 역량을 전통적인 추적되지 않은 역량과 구별 짓는 속성 중 하나예요.
암시적 역량 전달 (Implicit Capability Passing)
out이나 lg 같은 역량은 프로그램이 평소처럼 상호작용하는 객체예요. 객체 역량의 이런 측면은 인체공학적 표기를 이끌어내므로 그 강점 중 하나예요. 역량은 또한 어떤 효과를 실행할 권한을 확립하는 어떤 종류의 컨텍스트를 세우는 데도 자주 쓰여요.
예를 들어 동시성 시스템을 위한 Gears 프레임워크에는 외부 이벤트를 기다리는 동안 계산이 중단되도록(그리고 그 과정에서 취소될 수도 있도록) 허용하는 Async 역량이 있어요. 이것은 Async 클래스가 역량 트레이트를 확장하게 함으로써 모델링돼요.
import language.experimental.captureChecking
import caps.*
class File(path: String) extends ExclusiveCapability
extension (file: File)
def close(): Unit = ()
def println(message: String): Unit = ()
class Async extends SharedCapability
class Data
// A suspendable method using an Async capability
def readDataEventually(file: File)(using async: Async): Data = new Data
전통적인 역량의 흔한 문제는 그들이 필요한 모든 곳에 많은 역량을 파라미터로 전달하는 것이 금방 지루해진다는 거예요. Scala에서는 이것이 훨씬 덜 문제인데, 역량이 using 절을 통해 암시적 파라미터로 전달될 수 있기 때문이에요. 예를 들어 다음 메서드는 파라미터 async를 명시적으로 전달하지 않고 readDataEventually를 호출해요.
import language.experimental.captureChecking
import caps.*
class File(path: String) extends ExclusiveCapability
extension (file: File)
def close(): Unit = ()
def println(message: String): Unit = ()
class Async extends SharedCapability
class Data
// A suspendable method using an Async capability
def readDataEventually(file: File)(using async: Async): Data = new Data
def processData(using Async): Data =
val file = new File("~/some/path")
readDataEventually(file)
파라미터가 언급되지 않으므로 그 정의에서 이름도 필요 없어요. 그래서 위 메서드는 다음 더 명시적인 정의의 편리한 약어예요.
import language.experimental.captureChecking
import caps.*
class File(path: String) extends ExclusiveCapability
extension (file: File)
def close(): Unit = ()
def println(message: String): Unit = ()
class Async extends SharedCapability
class Data
// A suspendable method using an Async capability
def readDataEventually(file: File)(using async: Async): Data = new Data
def processData(using async: Async): Data =
val file = new File("~/some/path")
readDataEventually(file)(using async)
변경 (Mutation)
변경 가능한 변수와 변경 가능한 데이터 구조도 역량으로 간주돼요. 예를 들어 카운터를 증가시키고 읽는 함수 쌍을 만드는 메서드를 생각해볼게요.
import language.experimental.captureChecking
import caps.*
def counterOps =
var counter: Int = 0
val incr = () => counter += 1
val current = () => counter
(incr, current)
함수 incr는 타입 () ->{counter} Unit을 가지는데, 여기서 counter는 counterOps에 정의된 지역 변수예요. 이것은 함수를 호출할 때 카운터가 갱신된다는 사실을 기록해요.
우리는 변경 가능한 데이터에 대한 읽기와 쓰기 접근을 구분해요. 변경 가능한 데이터 구조 m에 대한 읽기 접근은 "분수" 읽기 전용 역량 m.rd를 부과하고, 쓰기 접근은 전체 역량 m을 부과해요. 분리 검사는 쓰기 접근의 대상이 별칭(aliasing)으로 가려질 수 없도록 보장해요. 이것은 Rust의 대여 검사(borrow checking)와 유사하지만, 지역(region) 대신 역량에 기반한 다른 메커니즘을 사용해요.
변경 가능한 데이터 구조는 Capability의 또 다른 하위 트레이트인 트레이트 Mutable을 확장해요. 그런 데이터에 쓰는 메서드는 update 수식자로 표시돼요. 예를 들어 append 버퍼를 위한 클래스가 있어요.
import language.experimental.captureChecking
import caps.*
abstract class Buffer[T] extends Mutable {
update def append(elem: T): Unit
def apply(pos: Int): T
def size: Int
}
append 메서드는 Buffer의 내용을 바꾸므로 update 수식자를 지녀요. 반면 apply와 size는 갱신이 없음이 보장되는 보통 메서드예요.
타입은 update 메서드에 대한 호출을 규제하는 데 쓰여요. Buffer 타입의 참조는 보통 메서드에만 접근을 허용해요. 참조가 Buffer^ 타입이면 update 메서드에도 접근을 허용해요.
예를 들어 버퍼 간 copy 메서드는 이렇게 쓸 수 있어요.
import language.experimental.captureChecking
import caps.*
abstract class Buffer[T] extends Mutable {
update def append(elem: T): Unit
def apply(pos: Int): T
def size: Int
}
def copy[T](from: Buffer[T], to: Buffer[T]^): Unit =
for i <- 0 until from.size do
to.append(from(i))
Buffer 인자의 타입은 어떤 버퍼가 수정되고 어떤 버퍼가 읽기 전용인지 분명하게 해요. C와 C^의 구분은 트레이트 Mutable을 확장하는 모든 타입 C에 적용된다는 점을 주목하세요. 다른 역량 타입의 경우 두 형태는 동등해요.
전역 역량 (Global Capabilities)
전통적인 객체 역량 시스템에서는 전역 역량이 배제돼요. 실제로 접근 제어를 단순히 스코프 규칙에 의존한다면, 전역 역량은 어디에서나 제한 없는 접근을 허용하므로 별로 말이 안 돼요.
하지만 추적된 역량을 사용하면 추적된 타입을 통해 접근을 제어하는 또 다른 수단이 있어요. 결과적으로 전역 역량이 허용될 수 있어요. 예를 들어 Console 객체가 있어요.
import language.experimental.captureChecking
import caps.*
class File(path: String) extends SharedCapability
extension (file: File)
def println(message: String): Unit = ()
object Console extends SharedCapability:
val in: File = new File("stdin")
val out: File = new File("stdout")
여기서 in과 out은 File 타입이므로 Console.in과 Console.out은 전역 역량이에요. 함수 () => Console.out.println("hi")는 타입 () ->{Console.out} Unit을 가질 거예요. 그것은 순수 함수를 기대하는 문맥에 전달될 수 없어요. Console을 참조하는 전역 객체는 uses 절에서 그 의존성을 선언해야 해요.
import language.experimental.captureChecking
import caps.*
class File(path: String) extends SharedCapability
extension (file: File)
def println(message: String): Unit = ()
object Console extends SharedCapability:
val in: File = new File("stdin")
val out: File = new File("stdout")
object SimpleLogger uses Console:
def log(str: String): Unit = Console.out.println(str)
Console.out 같은 전역 역량을 허용하는 것은 시스템의 아키텍처를 근본적으로 바꾸지 않고도 역량-안전하게 만들 수 있다는 뜻이므로 꽤 유용해요. 전통적인 역량 시스템에서는 호스트 시스템이 제공하는 모든 역량을 메인 진입점에 파라미터로 전달하고, 거기서 접근이 필요한 모든 함수로 전달해야 해요. 이것은 보통 코드베이스의 전역 리팩터링을 요구하고 더 복잡한 코드로 이어질 수 있어요.
요약
Scala 3의 역량은 전통적인 것보다 더 편리하고 더 표현력이 있어요. using 절이 호출 체인 아래로 역량을 암시적으로 전달하게 하고, 전역 역량이 그러한 호출 체인의 필요를 애초에 줄여주므로 더 편리해요. 그리고 추적된 역량이 계산의 효과를 기술하고 제약할 수 있게 하므로 더 표현력이 있어요. 게다가 읽기 전용과 갱신 역량을 구분할 수 있고, 갱신이 공유되지 않은 참조에만 일어나도록 보장할 수 있어요.