확장 메서드

확장 메서드 (Extension Methods)

확장 메서드는 타입이 정의된 다음에 그 타입에 메서드를 추가할 수 있게 해 줘요. 예시를 볼게요.

case class Circle(x: Double, y: Double, radius: Double)

extension (c: Circle)
  def circumference: Double = c.radius * math.Pi * 2

일반 메서드처럼 확장 메서드도 중위 . 표기로 호출할 수 있어요.

val circle = Circle(0, 0, 1)
circle.circumference

출처: Scala 3 Reference

본문

확장 메서드의 변환 (Translation of Extension Methods)

확장 메서드는 앞에 붙는 파라미터 섹션을 첫 번째 인자 목록으로 받는, 특별히 레이블이 붙은 메서드로 변환돼요. 여기서 <extension>으로 표현한 레이블은 컴파일러 내부용이에요. 그래서 위 circumference 정의는 다음 메서드로 변환되고, 이렇게도 호출할 수 있어요.

<extension> def circumference(c: Circle): Double = c.radius * math.Pi * 2

assert(circle.circumference == circumference(circle))

연산자 (Operators)

확장 메서드 문법으로 연산자도 정의할 수 있어요. 예시를 볼게요.

extension (x: String)
  def < (y: String): Boolean = ...
extension (x: Elem)
  def +: (xs: Seq[Elem]): Seq[Elem] = ...
extension (x: Number)
  infix def min (y: Number): Number = ...

"ab" < "c"
1 +: List(2, 3)
x min 3

위 세 정의는 다음으로 변환돼요.

<extension> def < (x: String)(y: String): Boolean = ...
<extension> def +: (xs: Seq[Elem])(x: Elem): Seq[Elem] = ...
<extension> infix def min(x: Number)(y: Number): Number = ...

오른쪽 결합 연산자 +:를 확장 메서드로 변환할 때 두 파라미터 xxs가 뒤바뀌는 점에 주의하세요. 이것은 오른쪽 결합 연산자를 일반 메서드로 구현하는 방식과 유사해요. 스칼라 컴파일러는 중위 연산 x +: xsxs.+:(x)로 전처리하기 때문에, 확장 메서드는 결국 첫 인자로 시퀀스를 받게 돼요. (즉 두 번의 뒤바뀜이 서로 상쇄되는 거예요.) 자세한 내용은 별도 문서를 참고하세요.

제네릭 확장 (Generic Extensions)

확장에 타입 파라미터를 추가해서 제네릭 타입을 확장할 수도 있어요. 예를 들면요.

extension [T](xs: List[T])
  def second = xs.tail.head

extension [T: Numeric](x: T)
  def + (y: T): T = summon[Numeric[T]].plus(x, y)

확장의 타입 파라미터는 메서드 자신의 타입 파라미터와도 결합할 수 있어요.

extension [T](xs: List[T])
  def sumBy[U: Numeric](f: T => U): U = ...

메서드 타입 파라미터에 일치하는 타입 인자는 평소처럼 전달돼요.

List("a", "bb", "ccc").sumBy[Int](_.length)

반면 extension 다음의 타입 파라미터에 일치하는 타입 인자는 메서드를 확장 메서드가 아닌 것으로 참조할 때만 전달할 수 있어요.

sumBy[String](List("a", "bb", "ccc"))(_.length)

또는 두 타입 인자를 모두 전달할 때:

sumBy[String](List("a", "bb", "ccc"))[Int](_.length)

확장은 using 절도 가질 수 있어요. 예를 들어 위 + 확장은 using 절로도 동일하게 쓸 수 있어요.

extension [T](x: T)(using n: Numeric[T])
  def + (y: T): T = n.plus(x, y)

집합 확장 (Collective Extensions)

가끔은 왼쪽 파라미터 타입을 공유하는 여러 확장 메서드를 정의하고 싶을 때가 있어요. 이때는 공통 파라미터를 하나의 extension으로 "끌어내고" 모든 메서드를 중괄호나 들여쓰기 영역으로 감쌀 수 있어요. 예시를 볼게요.

extension (ss: Seq[String])

  def longestStrings: Seq[String] =
    val maxLength = ss.map(_.length).max
    ss.filter(_.length == maxLength)

  def longestString: String = longestStrings.head

같은 것을 중괄호로도 쓸 수 있어요. (참고로 중괄호 안에서도 들여쓰기 영역을 쓸 수 있어요.)

extension (ss: Seq[String]) {

  def longestStrings: Seq[String] = {
    val maxLength = ss.map(_.length).max
    ss.filter(_.length == maxLength)
  }

  def longestString: String = longestStrings.head
}

longestString의 오른쪽 변을 주목하세요. longestStrings를 직접 호출하면서 공통으로 확장된 값 ss를 리시버로 암묵적으로 가정하고 있어요.

이런 집합 확장은 각 메서드를 따로 정의한 개별 확장의 약칭이에요. 예를 들어 첫 번째 확장은 다음으로 확장돼요.

extension (ss: Seq[String])
  def longestStrings: Seq[String] =
    val maxLength = ss.map(_.length).max
    ss.filter(_.length == maxLength)

extension (ss: Seq[String])
  def longestString: String = ss.longestStrings.head

집합 확장은 타입 파라미터를 가질 수도 있고 using 절도 가질 수 있어요. 예시예요.

extension [T](xs: List[T])(using Ordering[T])
  def smallest(n: Int): List[T] = xs.sorted.take(n)
  def smallestIndices(n: Int): List[Int] =
    val limit = smallest(n).max
    xs.zipWithIndex.collect { case (x, i) if x <= limit => i }

확장 메서드 호출의 변환 (Translation of Calls to Extension Methods)

확장 메서드 참조를 변환하려면 컴파일러가 그 확장 메서드를 알아야 해요. 이런 경우 확장 메서드가 참조 지점에서 **적용 가능(applicable)**하다고 말해요. 확장 메서드가 적용 가능한 방법은 네 가지가 있어요.

  1. 확장 메서드가 참조를 둘러싼 스코프에서 정의되거나 상속되거나 import되어 단순 이름으로 보이는 경우.
  2. 확장 메서드가 참조 지점에서 보이는 어떤 given 인스턴스의 멤버인 경우.
  3. 참조가 r.m 형태인데 확장 메서드가 r의 타입의 암시적 스코프(implicit scope)에 정의된 경우.
  4. 참조가 r.m 형태인데 확장 메서드가 r의 타입의 암시적 스코프에 있는 어떤 given 인스턴스에 정의된 경우.

첫 번째 규칙의 예시를 볼게요.

trait IntOps:
  extension (i: Int) def isZero: Boolean = i == 0

  extension (i: Int) def safeMod(x: Int): Option[Int] =
    // extension method defined in same scope IntOps
    if x.isZero then None
    else Some(i % x)

object IntOpsEx extends IntOps:
  extension (i: Int) def safeDiv(x: Int): Option[Int] =
    // extension method brought into scope via inheritance from IntOps
    if x.isZero then None
    else Some(i / x)

trait SafeDiv:
  import IntOpsEx.* // brings safeDiv and safeMod into scope

  extension (i: Int) def divide(d: Int): Option[(Int, Int)] =
    // extension methods imported and thus in scope
    (i.safeDiv(d), i.safeMod(d)) match
      case (Some(d), Some(r)) => Some((d, r))
      case _ => None

두 번째 규칙에 따르면 확장 메서드를 담은 given 인스턴스를 정의해서 확장 메서드를 사용할 수 있게 만들 수 있어요. 이렇게요.

given ops1: IntOps()  // brings safeMod into scope

1.safeMod(2)

세 번째와 네 번째 규칙에 따르면 확장 메서드는 리시버 타입의 암시적 스코프에 있거나, 그 스코프에 있는 given 인스턴스 안에 있으면 사용할 수 있어요. 예시를 볼게요.

class List[T]:
  ...
object List:
  ...
  extension [T](xs: List[List[T]])
    def flatten: List[T] = xs.foldLeft(List.empty[T])(_ ++ _)

  given [T: Ordering] => Ordering[List[T]]:
    extension (xs: List[T])
      def < (ys: List[T]): Boolean = ...
end List

// extension method available since it is in the implicit scope
// of List[List[Int]]
List(List(1, 2), List(3, 4)).flatten

// extension method available since it is in the given Ordering[List[T]],
// which is itself in the implicit scope of List[Int]
List(1, 2) < List(3)

선택을 확장 메서드로 해석하는 정확한 규칙은 다음과 같아요.

e.m[Ts]라는 선택을 가정해 봅시다. 여기서 me의 멤버가 아니고, 타입 인자 [Ts]는 선택적이며, T는 기대 타입이에요. 다음 두 다시 쓰기를 순서대로 시도해요.

  1. 선택을 m[Ts](e)로 다시 쓰고 타입 검사하는데, 이름 해석 규칙에 다음 사소한 수정을 적용해요.

    • m이 모두 같은 중첩 레벨에 있는 여러 import로 import되었다면, 모호성 오류를 내는 대신 각 import를 확장 메서드로 시도해요. 오류 없이 타입 검사를 통과하는 확장으로 이어지는 import가 하나뿐이면 그 확장을 선택해요. 여러 그런 import가 있지만 와일드카드 import가 아닌 것이 하나뿐이면, 그 import의 확장을 선택해요. 그렇지 않으면 모호한 참조 오류를 보고해요.
    • 참고: import 규칙의 이 완화는 메서드 m이 확장 메서드로 쓰일 때만 적용돼요. 접두사 형태의 일반 메서드로 쓰이면 일반 import 규칙이 적용돼서, 여러 곳에서 m을 import하면 모호성 오류가 날 수 있어요.
  2. 첫 번째 다시 쓰기가 기대 타입 T로 타입 검사를 통과하지 못하고, 어떤 적격한 객체 o에 확장 메서드 m이 있다면, 선택을 o.m[Ts](e)로 다시 써요. 객체 o는 다음 중 하나면 적격해요.

    • oT의 암시적 스코프의 일부이거나,
    • o가 애플리케이션 지점에서 보이는 given 인스턴스이거나,
    • oT의 암시적 스코프에 있는 given 인스턴스이거나.

    이 두 번째 다시 쓰기는 컴파일러가 Tm을 담은 타입으로의 암시적 변환도 시도하는 시점에 시도돼요. 다시 쓰는 방법이 둘 이상이면 모호성 오류가 나요.

확장 메서드는 앞에 식 없이 단순 식별자로도 참조할 수 있어요. 확장 메서드 f의 본문에 식별자 g가 나타나는데 g가 같은 집합 확장 안에 정의된 확장 메서드를 가리킨다면,

extension (x: T)
  def f ... = ... g ...
  def g ...

그 식별자는 x.g로 다시 쓰여요. fg가 같은 메서드여도 마찬가지예요. 예시를 볼게요.

extension (s: String)
  def position(ch: Char, n: Int): Int =
    if n < s.length && s(n) != ch then position(ch, n + 1)
    else n

이 경우 재귀 호출 position(ch, n + 1)s.position(ch, n + 1)로 확장돼요. 전체 확장 메서드는 다음으로 다시 써져요.

def position(s: String)(ch: Char, n: Int): Int =
  if n < s.length && s(n) != ch then position(s)(ch, n + 1)
  else n

문법 (Syntax)

확장 메서드와 집합 확장에 대한, 현재 문법 기준의 문법 변경은 다음과 같아요.

BlockStat         ::=  ... | Extension
TemplateStat      ::=  ... | Extension
TopStat           ::=  ... | Extension
Extension         ::=  ‘extension’ [DefTypeParamClause] {UsingParamClause}
                       ‘(’ DefParam ‘)’ {UsingParamClause} ExtMethods
ExtMethods        ::=  ExtMethod | [nl] <<< ExtMethod {semi ExtMethod} >>>
ExtMethod         ::=  {Annotation [nl]} {Modifier} ‘def’ DefDef

위에서 생성 규칙 ExtMethods의 표기 <<< ts >>>는 다음과 같이 정의돼요.

<<< ts >>>        ::=  ‘{’ ts ‘}’ | indent ts outdent

extension은 소프트 키워드(soft keyword)예요. 문장 시작에 나타나고 그 뒤에 [ 또는 (가 올 때만 키워드로 인식돼요. 그 외의 모든 경우에는 식별자로 취급돼요.