암시적 해석의 변경 사항
암시적 해석의 변경 사항 (Changes in Implicit Resolution)
이 절에서는 Scala 3에서 새로 도입된 given과 기존 스타일의 implicit 모두에 적용되는 암시적 해석의 변경 사항을 설명해요. 암시적 해석은 새 알고리즘을 사용하는데, 성능을 위해 암시적 결과를 더 적극적으로 캐시(cache)해요. 언어 차원에서 implicit에 영향을 주는 변경 사항도 몇 가지 있죠.
본문
- 암시적 값의 타입과 암시적 메서드의 결과 타입은 반드시 명시적으로 선언해야 해요. 예외는 타입을 여전히 추론할 수 있는 지역 블록(local block) 안의 값뿐이에요.
class C {
val ctx: Context = ... // ok
/*!*/ implicit val x = ... // error: type must be given explicitly
/*!*/ implicit def y = ... // error: type must be given explicitly
}
val y = {
implicit val ctx = this.ctx // ok
...
}
- 이제는 암시적 값을 고를 때 중첩 여부(nesting)를 고려해요. 다음 시나리오를 볼게요.
def f(implicit i: C) = {
def g(implicit j: C) = {
implicitly[C]
}
}
이 코드에서는 implicitly 호출이 j로 해석돼요. j가 i보다 더 깊게 중첩되어 있기 때문이죠. 이전에는 이 상황이 모호성(ambiguity) 오류로 이어졌어요. 또, 중첩 정의에 의해 implicit이 가려지는(shadowing) 이전의 암시적 탐색 실패 가능성은 더 이상 적용되지 않아요.
- 패키지 접두어(package prefix)가 더 이상 타입의 암시적 탐색 범위(implicit search scope)에 기여하지 않아요. 예를 들어볼게요.
package p
given a: A = A()
object o:
given b: B = B()
type C
타입 C가 정의된 지점에서는 a와 b 둘 다 implicit으로 보여요. 하지만 패키지 p 밖에서 p.o.C를 참조하면 암시적 탐색 범위에는 b만 들어오고 a는 들어오지 않아요.
더 자세히 말하면, 타입의 암시적 스코프를 구성하는 규칙은 다음과 같아요.
정의: 어떤 참조(reference)가 객체(object)나 클래스, 트레이트, 추상 타입, 불투명 타입 별칭(opaque type alias), 매치 타입 별칭(match type alias)을 가리키면 이를 앵커(anchor) 라고 불러요. 패키지와 패키지 객체에 대한 참조는 -source:3.0-migration에서만 앵커가 돼요. 불투명 타입 별칭은 그 별칭이 보이는 스코프 밖에서만 앵커로 간주돼요.
정의: 타입 T의 앵커는 다음과 같이 정의된 참조들의 집합이에요.
T가 앵커에 대한 참조라면,T자신에 더해T가P#A형태일 때는P의 앵커를 포함해요.T가U의 별칭이라면,U의 앵커를 포함해요.T가 타입 매개변수에 대한 참조라면, 두 경계(bound)의 앵커를 모두 합한 집합이에요.T가 싱글턴 참조라면, 기저 타입(underlying type)의 앵커에 더해T가(P#x).type형태일 때는P의 앵커를 포함해요.T가 정적 객체o의 this 타입o.this라면, 그 객체에 대한 항(term) 참조o.type의 앵커를 포함해요.T가 어떤 다른 this 타입P.this.type이라면,P의 앵커를 포함해요.T가 그 밖의 다른 타입이라면,T의 각 구성 타입(constituent type)의 앵커를 모두 합한 집합이에요.
정의: 타입 T의 암시적 스코프는 항 참조들의 집합 S 중 다음을 만족하는 가장 작은 집합이에요.
T가 클래스에 대한 참조라면,S는 존재할 경우 그 클래스의 컴패니언 객체에 대한 참조와,T의 모든 부모 클래스들의 암시적 스코프를 포함해요.T가 객체에 대한 참조라면,S는T자신과T의 모든 부모 클래스들의 암시적 스코프를 포함해요.T가A라는 이름의 불투명 타입 별칭에 대한 참조라면,S는 그 타입과 같은 스코프에 정의된A객체(존재할 경우)에 대한 참조와,T의 기저 타입 또는 경계의 암시적 스코프를 포함해요.T가A라는 이름의 추상 타입 또는 매치 타입 별칭에 대한 참조라면,S는 그 타입과 같은 스코프에 정의된A객체(존재할 경우)에 대한 참조와,T의 given 경계들의 암시적 스코프를 포함해요.T가p.A형태의 앵커에 대한 참조라면,S는 경로p위의 모든 항 참조도 포함해요.T가 그 밖의 다른 타입이라면,S는T의 모든 앵커의 암시적 스코프를 포함해요.
- 모호성(ambiguity) 오류의 처리가 바뀌었어요. 암시적 탐색의 어떤 재귀 단계에서 모호성이 발생하면, 그 모호성이 호출자에게 전파돼요.
다음 정의들이 있다고 해볼게요.
class A
class B extends C
class C
implicit def a1: A
implicit def a2: A
implicit def b(implicit a: A): B
implicit def c: C
그리고 쿼리가 implicitly[C]라고 할게요.
이 쿼리는 이제 모호한(ambiguous) 것으로 분류돼요. 사실 말이 되는 판단이죠. 어차피 가능한 해가 두 개(b(a1)와 b(a2))이고, 둘 중 어느 하나가 다른 것보다 낫지도 않으며, 둘 다 세 번째 해인 c보다는 낫기 때문이에요. 반면 Scala 2에서는 A에 대한 탐색을 모호함으로 거부하고, 그 뒤 b(implicitly[A]) 쿼리를 평범한 실패로 분류해서 결국 c라는 대안이 해로 선택될 수 있었어요!
Scala 2의 이런 다소 난해한 모호성 동작은 암시적 해석에서 "부정"(negated) 탐색에 해당하는 기법을 구현하는 데 이용돼 왔어요. 어떤 쿼리 Q2가 성공하면 쿼리 Q1이 실패하고, Q2가 실패하면 Q1이 성공하는 방식이죠. 새로 정리된 동작에서는 이런 기법이 더 이상 통하지 않아요. 그 대신 부정을 직접 구현하는 새로운 특수 타입 scala.util.NotGiven이 도입됐어요. 임의의 쿼리 타입 Q에 대해 NotGiven[Q]는 Q에 대한 암시적 탐색이 실패할 때에만, 그리고 정확히 그때 성공해요.
- 발산(divergence) 오류의 처리도 바뀌었어요. 발산하는 implicit은 평범한 실패로 취급되고, 그 후에도 대안들을 계속 시도해요. 이것도 이치에 맞아요. 발산하는 implicit을 만났다는 건 해당 경로에서는 유한한 해를 찾을 수 없다고 가정한 것이고, 다른 경로는 여전히 시도해 볼 수 있으니까요. 반면 Scala 2에서는 대부분(전부는 아니지만)의 발산 오류가 암시적 탐색 전체를 종료시켰어요.
진행 중인 탐색 기록을 새 후보와 비교하는 발산 검사가, 같은 owner에 선언되었고 선언된 타입을 공유하는 형제 given(sibling given)들도 같은 후보로 인식하도록 개선됐어요. 이런 형제들은 동일한 컨텍스트 매개변수를 요구하므로, 같은 프로토타입 형태 아래에서 하나를 탐색한 뒤 다른 하나를 탐색해도 새 진전이 있을 수 없어요. 이 규칙이 없으면 서로 닮은 given 무리(예를 들어 각각 같은 바깥 타입 클래스가 필요한 여러 타입 클래스 인스턴스)가 식 개수 제한에 걸리기 전에 모든 순열을 뒤져야 할 테니까요.
- Scala 2는 이름에 의한 호출(call-by-name) 매개변수를 갖는 암시적 변환에 값에 의한 호출(call-by-value) 매개변수를 갖는 암시적 변환보다 낮은 우선순위를 부여했어요. Scala 3에서는 이 구분을 없앴죠. 그래서 다음 코드 조각은 Scala 3에서 모호해요.
implicit def conv1(x: Int): A = new A(x)
implicit def conv2(x: => Int): A = new A(x)
def buzz(y: A) = ???
buzz(1) // error: ambiguous
- 오버로드된 후보나 암시적 후보의 집합 중 가장 특수한(specific) 대안을 고르는 규칙이 컨텍스트 매개변수를 고려하도록 다듬어졌어요. 그 외의 조건이 같다면, 컨텍스트 매개변수를 받는 대안은 아무것도 받지 않는 대안보다 덜 특수한 것으로 간주돼요. 두 대안 모두 컨텍스트 매개변수를 받는다면, 둘을 보통 매개변수를 갖는 메서드인 것처럼 취급해서 선택을 시도해요. SLS §6.26.3의 다음 문단이 이 변경의 영향을 받아요.
원래 버전:
대안
A는,B에 대한A의 상대 가중치가A에 대한B의 상대 가중치보다 크면B보다 더 특수하다.
수정된 버전:
대안
A는 다음 중 하나라도 만족하면 대안B보다 더 특수하다.
B에 대한A의 상대 가중치가A에 대한B의 상대 가중치보다 크거나,- 상대 가중치가 같고,
A는 암시적 매개변수를 받지 않지만B는 받거나,- 상대 가중치가 같고
A와B둘 다 암시적 매개변수를 받되, 어느 대안이든 모든 암시적 매개변수를 보통 매개변수로 바꿨을 때A가B보다 더 특수하다.
- 상속 깊이에 기반한 기존의 implicit 구분(disambiguation) 규칙이 추이적(transitive) 이 되도록 다듬어졌어요. 추이성은 탐색 결과가 컴파일 순서와 무관함을 보장하는 데 중요해요. 기존 규칙이 추이성을 위반했던 시나리오를 보여줄게요.
class A extends B
object A { given a ... }
class B
object B extends C { given b ... }
class C { given c }
여기서 a는 b보다 더 특수해요. 컴패니언 클래스 A가 컴패니언 클래스 B의 하위 클래스이기 때문이죠. 또 b는 c보다 더 특수해요. 객체 B가 클래스 C를 상속하니까요. 하지만 a는 c보다 더 특수하지 않아요. 이 말은 a, b, c가 모두 적용 가능한 implicit이라면, 비교하는 순서에 따라 결과가 달라진다는 뜻이에요. b와 c를 먼저 비교하면 b를 남기고 c를 버려요. 그다음 a를 b와 비교하면 a를 남기죠. 하지만 a를 c와 먼저 비교하면 모호성 오류로 실패해요.
새 규칙은 다음과 같아요. A에 정의된 implicit a는 다음 조건 중 하나를 만족하면 B에 정의된 implicit b보다 더 특수해요.
A가B를 상속하거나,A가 객체이고A의 컴패니언 클래스가B를 상속하거나,A와B가 모두 객체이고,B가 기반 클래스로부터 어떤 implicit 멤버도 상속받지 않으며(*),A의 컴패니언 클래스가B의 컴패니언 클래스를 상속한다.
조건 (*)은 새로 추가된 거예요. 정의된 관계가 추이적임을 보장하는 데 필요해요.
- given 구분(disambiguation)이 바뀌었어요. 기대 타입과 모두 일치하는 두 given을 비교할 때, 예전에는 오버로드 해석에 맞춰 더 특수한 것을 골랐어요. Scala 3.5부터는 대신 더 일반적인 것을 골라요. Scala 3.5-migration으로 컴파일하면 선호도가 바뀐 모든 경우에 경고가 출력돼요. 예시를 볼게요.
class A
class B extends A
class C extends A
given A = A()
given B = B()
given C = C()
summon[A] // was ambiguous, will now return `given_A`
- 다음 변경은 현재
-source future에서 활성화되어 있어요.
암시적 해석이 이제, 런타임에 무한 루프로 이어질 수 있는 재귀 given을 생성하지 않도록 해요. 예시를 보여드릴게요.
object Prices {
opaque type Price = BigDecimal
object Price{
given Ordering[Price] = summon[Ordering[BigDecimal]] // was error, now avoided
}
}
이전에는 암시적 해석이 summon을 Price 안의 given으로 해석해서 무한 루프로 이어졌어요(그 경우에 경고가 발행됐죠). 이제는 BigDecimal의 기저 given을 대신 사용해요. 이렇게 하기 위해 암시적 탐색 규칙에 다음을 추가했어요.
다음과 같은 형태의 given 정의
G의 구현을 검사하면서 암시적 탐색을 수행할 때given ... = ....
G로 되돌아가게 하는 탐색 결과나,G와 owner가 같으면서 소스에서G보다 뒤에 오는 given으로 이어지는 탐색 결과는 모두 버려요.
이 새로운 동작은 현재 source.future에서 활성화되어 있고, 가장 빠르면 Scala 3.6에서 켜질 예정이에요. 이전 소스 버전에서의 동작은 다음과 같아요.
- Scala 3.3: 변경 없음
- Scala 3.4:
3.future에서 동작이 바뀔 위치에 경고가 발행돼요. - Scala 3.5:
3.future에서 동작이 바뀔 위치에 오류가 발행돼요.
기존 스타일의 implicit 정의는 이 변경의 영향을 받지 않아요.