역량 다형성
역량 다형성 (Capability Polymorphism)
소개
캡처 검사는 두 가지 상보적인 스타일로 캡처-다형 프로그래밍을 지원해요.
본문
- 암시적 캡처 다형성 — 기본값이며 구문 오버헤드가 최소예요.
- 명시적 캡처 다형성 — 프로그래머가 명시적인 제네릭 파라미터를 통해 캡처 집합을 직접 추상화할 수 있어요.
암시적 캡처 다형성과 명시적 캡처 다형성의 차이는, 하위 타입을 통한 다형성과 타입 파라미터/제네릭을 통한 파라메트릭 다형성의 차이와 유사해요.
암시적 다형성
고차 함수 같은 많은 경우에 우리는 캡처 타입에 대해 다형적이기 위해 새 구문이 필요하지 않아요. 전형적인 예는 리스트 위의 map이에요.
trait List[+A]:
// Works for pure functions AND capturing functions!
def map[B](f: A => B): List[B]
이전 절에서 확립한 관례 덕분에, f: A => B는 캡처 검사 아래에서 f: A ->{any} B로 번역돼요. 이는 함수 인자 f가 어떤 역량이든 캡처할 수 있다는 뜻이에요. 즉 역량을 부작용을 유도하는 유일한 수단이라고 생각한다면, map은 f의 효과를 가질 거예요. 그러면 역량 다형성이 효과 다형성과 같아져요. 표기와 제네릭 타입의 캡처 터널링 메커니즘을 신중히 선택함으로써, 우리는 효과 다형성을 공짜로 얻고 List 같은 eager 컬렉션 타입에는 시그니처 변경이 전혀 필요하지 않아요.
이전 절의 LzyList 같은 지연 컬렉션과 대조해보면, 암시적 역량 다형성이 map의 결과에 추가 캡처 집합을 유도해요.
extension [A](xs: LzyList[A]^)
def map[B](f: A => B): LzyList[B]^{xs, f}
계산 중에만 f를 사용하는 eager 버전과 달리, 지연 대응물은 계산을 지연시켜 원래 리스트와 함수가 결과에 캡처돼요. 이 관계는 경로-의존 결과 캡처 집합 {xs, f} 덕분에 간결하게 표현될 수 있고, 명시적 제네릭 효과 파라미터를 가진 더 전통적인 효과 타입 시스템에서는 표현하기가 꽤 번거로울 거예요.
명시적 다형성
어떤 상황에서는 정의를 캡처 집합으로 파라미터화하는 것이 편리하거나 필요해요. 이렇게 하면 API가 자기 클라이언트가 사용할 수 있는 역량을 정확히 명시할 수 있어요. Listener들을 저장하는 Source를 생각해볼게요.
import language.experimental.captureChecking
import caps.*
trait Listener
class Source[X^]:
private var listeners: Set[Listener^{X}] = Set.empty
def register(x: Listener^{X}): Unit =
listeners += x
def allListeners: Set[Listener^{X}] = listeners
여기서 X^는 캡처 집합 변수예요. 그것은 클래스 본문 전체에서 캡처 집합 안에 나타날 수 있어요. 필드 listeners는 정확히 X를 캡처하는 리스너들만 담고, register는 그런 리스너들만 받아들여요.
내부 동작 (Under the hood)
사용자가 제공한 경계가 없는 캡처 집합 변수는 >: {} <: {caps.any} 구간——캡처 집합의 완전한 격자——에 걸쳐 있어요. 그것들은 도메인이 "모든 타입"이 아니라 "모든 캡처 집합"인 타입 파라미터처럼 동작해요.
내부적으로 캡처 집합 변수는 특별한 경계를 가진 일반 타입 파라미터로 구현돼요.
class Source[X >: CapSet <: CapSet^]:
...
CapSet은 캡처 집합 변수를 구분하기 위해 내부적으로 사용되는 caps의 sealed 마커 트레이트예요. 그것은 인스턴스화되거나 확장될 수 없어요. 비-캡처-검사 코드에서 CapSet^{a}와 CapSet^{a,b}는 평범한 CapSet으로 지워지지만, 캡처 검사가 켜지면 그 캡처 집합은 구분된 채 유지돼요. 이 표현은 구현 세부사항이고 직접 사용하면 안 돼요. 컴파일러가 미래에 CapSet을 완전히 지울 수도 있기 때문이에요.
인스턴스화와 추론 (Instantiation and inference)
캡처 집합 변수는 일반 타입 변수와 같은 방식으로 추론돼요. 또한 캡처 집합 리터럴이나 다른 캡처 집합 변수로 명시적으로 인스턴스화될 수 있어요.
import language.experimental.captureChecking
import caps.*
trait Listener
class Source[X^]:
private var listeners: Set[Listener^{X}] = Set.empty
def register(x: Listener^{X}): Unit =
listeners += x
def allListeners: Set[Listener^{X}] = listeners
import language.experimental.captureChecking
import caps.*
trait List[+A]:
def map[B](f: A => B): List[B]
def foreach(f: A => Unit): Unit
class Async extends SharedCapability
def listener(a: Async): Listener^{a} = ???
def test1[X^](async1: Async, others: List[Async^{X}]) =
val src = Source[{async1, X}]
src.register(listener(async1))
others.map(x => listener(x)).foreach(src.register)
val ls: Set[Listener^{async1, X}] = src.allListeners
여기서 src는 특정 역량 async1 또는 others의 어떤 원소를 캡처할 수 있는 리스너들을 받아들여요. 결과 allListeners 메서드는 이 관계를 반영해요.
컬렉션 변환 (Transforming collections)
명시적 캡처 파라미터의 전형적인 사용은 Future 같은 캡처 값들의 컬렉션을 변환할 때 생겨요. 이런 경우 API는 입력 컬렉션의 원소가 캡처하는 역량이 무엇이든 출력 원소도 캡처한다는 것을 보장해야 해요.
다음 예시는 미정렬 Set의 future들을 가져와, 완료되는 순서대로 결과를 내는 Stream을 만든다. 명시적 캡처 변수 C^를 사용하여, 시그니처는 입력 future들의 누적 캡처 집합이 결과 스트림에서 보존됨을 표현해요.
def collect[T, C^](fs: Set[Future[T]^{C}])(using Async^): Stream[Future[T]^{C}] =
val channel = Channel()
fs.forEach.(_.onComplete(v => channel.send(v)))
Stream.of(channel)
변경 가능한 객체의 진화 추적 (Tracking the evolution of mutable objects)
명시적 캡처 파라미터의 흔한 사용 사례는 변경으로 인해 변경 가능한 객체의 도달 가능한 역량이 커질 때예요. 예를 들어 효과가 있는 이터레이터를 연결하는 경우:
class ConcatIterator[A, C^](var iterators: mutable.ListBuffer[IterableOnce[A]^{C}]):
def concat(it: IterableOnce[A]^): ConcatIterator[A, {C, it}]^{this, it} =
iterators += it // ^
this // track contents of `it` in the result
이런 경우 타입 시스템은 변경 후 이터레이터의 기존 별칭이 무효화되도록 보장해야 해요. 이것은 현재 개발 중인 변경 추적(mutation tracking)과 분리 추적(separation tracking)으로 처리돼요.
암시적일까, 명시적일까? (Shall I Be Implicit or Explicit?)
암시적 역량 다형성은 가장 흔한 사용 사례를 다루도록 의도돼요. 그것은 기존 함수형 프로그래밍 관용구와 매끄럽게 통합되고, Scala 표준 컬렉션 라이브러리를 최소한의 변경으로 캡처 검사에 맞춰 개조할 만큼 표현력이 충분했어요.
명시적 역량 다형성은 API의 캡처 관계가 그 시그니처에 직접 명시되어야 할 때만 도입돼요. 여기까지 우리는 그렇게 함으로써 명확성이 향상되는 몇 가지 예시를 봤어요: 캡처 집합을 명시적으로 이름 붙이기, 컬렉션의 캡처 보존하기, 변경이 객체의 캡처를 어떻게 바꾸는지 기술하기.
명시적 다형성의 단점은 추가 구문 오버헤드예요. 특히 여러 관련 캡처 집합을 결합하는 API에서 캡처 파라미터가 시그니처를 더 장황하게 만들 수 있어요.
권장사항: 기본적으로 암시적 다형성을 선호하세요. 의도된 캡처 관계를 암시적으로 표현할 수 없거나 그렇지 않으면 불분명할 때만 명시적 캡처 파라미터를 도입하세요.
역량 멤버 (Capability Members)
타입 파라미터가 타입 멤버로 대체될 수 있는 것과 같은 방식으로, 캡처 파라미터도 역량 멤버(capability member)로 도입될 수 있어요. 앞선 예시
import language.experimental.captureChecking
import caps.*
trait Listener
class Source[X^]:
private var listeners: Set[Listener^{X}] = Set.empty
는 대신 이렇게 쓸 수 있어요.
class Source:
type X^
private var listeners: Set[Listener^{this.X}] = Set.empty
def register(l: Listener^{this.X]): Unit =
listeners += l
def allListeners: Set[Listener^{this.X}] = listeners
역량 멤버는 경로-의존 캡처 집합 변수처럼 동작해요. {this.X} 같은 경로를 사용해 캡처 어노테이션에 나타날 수 있어요.
역량 멤버는 또한 캡처 집합 경계를 가질 수 있어서, 그것이 포함할 수 있는 역량을 제한할 수 있어요.
import language.experimental.captureChecking
import caps.*
trait Event
trait Reactor:
type Cap^ <: {caps.any}
def onEvent(h: Event ->{this.Cap} Unit): Unit
각 Reactor 구현은 Cap^을 더 구체적인 캡처 집합으로 정제할 수 있어요.
import language.experimental.captureChecking
import caps.*
trait Event
trait Reactor:
type Cap^ <: {caps.any}
def onEvent(h: Event ->{this.Cap} Unit): Unit
import language.experimental.captureChecking
import caps.*
object ui extends SharedCapability
object log extends SharedCapability
trait GUIReactor extends Reactor:
type Cap^ <: {ui, log}
여기서 GUIReactor는 이벤트 핸들러가 ui, log, 또는 그 부분 집합만 캡처할 수 있음을 명시해요. onEvent 메서드는 경로-의존 캡처 집합 {this.Cap}을 통해 이를 표현해요.
역량 멤버는 캡처 정보가 명시적 캡처 파라미터로 표현되는 대신 객체 정체성에 묶이거나 추상 인터페이스의 일부를 이루어야 할 때 유용해요.
고급 사용: 역량 멤버의 더 고급 사용 사례는 여기에서 논의해요.