컨텍스트 함수

컨텍스트 함수 (Context Functions)

컨텍스트 함수는 (오직) 컨텍스트 파라미터만 갖는 함수예요. 그 타입은 컨텍스트 함수 타입(context function type)이에요. 컨텍스트 함수 타입의 예시를 볼게요.

import scala.concurrent.ExecutionContext

type Executable[T] = ExecutionContext ?=> T

컨텍스트 함수는 "화살표" 기호로 ?=>를 써요. 컨텍스트 파라미터를 가진 메서드가 적용되는 것과 같은 방식으로, 합성된 인자에 적용돼요. 예를 들면요.

given ec: ExecutionContext = ...

def f(x: Int): ExecutionContext ?=> Int = ...

// could be written as follows with the type alias from above
// def f(x: Int): Executable[Int] = ...

f(2)(using ec)   // explicit argument
f(2)             // argument is inferred

반대로, 표현식 E의 기대 타입이 컨텍스트 함수 타입 (T_1, ..., T_n) ?=> U이고 E가 이미 컨텍스트 함수 리터럴이 아니라면, E는 다음처럼 다시 써서 컨텍스트 함수 리터럴로 변환돼요.

(x_1: T1, ..., x_n: Tn) ?=> E

여기서 이름 x_1, ..., x_n은 임의예요. 이 확장은 표현식 E를 타입 검사하기 전에 수행되므로, x_1, ..., x_nE에서 given으로 사용 가능해요.

타입과 마찬가지로 컨텍스트 함수 리터럴도 파라미터와 결과 사이의 화살표로 ?=>를 써요. 일반 함수 리터럴과 다른 점은 그 타입이 컨텍스트 함수 타입이라는 거예요.

예를 들어 앞선 정의로 계속해서,

def g(arg: Executable[Int]) = ...

g(22)      // is expanded to g((ev: ExecutionContext) ?=> 22)

g(f(2))    // is expanded to g((ev: ExecutionContext) ?=> f(2)(using ev))

g((ctx: ExecutionContext) ?=> f(3))  // is expanded to g((ctx: ExecutionContext) ?=> f(3)(using ctx))
g((ctx: ExecutionContext) ?=> f(3)(using ctx)) // is left as it is

출처: Scala 3 Reference

본문

예시: 빌더 패턴 (Example: Builder Pattern)

컨텍스트 함수 타입은 상당한 표현력을 갖고 있어요. 예를 들어 "빌더 패턴"을 지원하는 방법을 볼게요. 목표는 이런 테이블을 만드는 거예요.

table {
  row {
    cell("top left")
    cell("top right")
  }
  row {
    cell("bottom left")
    cell("bottom right")
  }
}

아이디어는 add로 요소를 추가할 수 있게 해 주는 TableRow 클래스를 정의하는 거예요.

import scala.collection.mutable.ArrayBuffer

class Table:
  val rows = new ArrayBuffer[Row]
  def add(r: Row): Unit = rows += r
  override def toString = rows.mkString("Table(", ", ", ")")

class Row:
  val cells = new ArrayBuffer[Cell]
  def add(c: Cell): Unit = cells += c
  override def toString = cells.mkString("Row(", ", ", ")")

case class Cell(elem: String)

그런 다음 table, row, cell 생성 메서드를 파라미터로 컨텍스트 함수 타입을 쓰게 정의하면, 그렇지 않으면 필요한 배선 보일러플레이트를 피할 수 있어요.

def table(init: Table ?=> Unit) =
  given t: Table = Table()
  init
  t

def row(init: Row ?=> Unit)(using t: Table) =
  given r: Row = Row()
  init
  t.add(r)

def cell(str: String)(using r: Row) =
  r.add(new Cell(str))

이렇게 설정하면 위 테이블 생성 코드가 컴파일되고 다음으로 확장돼요.

table { ($t: Table) ?=>

  row { ($r: Row) ?=>
    cell("top left")(using $r)
    cell("top right")(using $r)
  }(using $t)

  row { ($r: Row) ?=>
    cell("bottom left")(using $r)
    cell("bottom right")(using $r)
  }(using $t)
}

예시: 사후 조건 (Example: Postconditions)

더 큰 예시로, 임의의 사후 조건(postcondition)을 검사하는 구성을 정의하는 방법을 볼게요. 이때 확장 메서드 ensuring을 써서 검사된 결과를 그냥 result로 참조할 수 있게 해요. 이 예시는 불투명 타입 별칭(opaque type alias), 컨텍스트 함수 타입, 확장 메서드를 결합해서 오버헤드가 없는(zero-overhead) 추상화를 제공해요.

object PostConditions:
  opaque type WrappedResult[T] = T

  def result[T](using r: WrappedResult[T]): T = r

  extension [T](x: T)
    def ensuring(condition: WrappedResult[T] ?=> Boolean): T =
      assert(condition(using x))
      x
end PostConditions
import PostConditions.{ensuring, result}

val s = List(1, 2, 3).sum.ensuring(result == 6)

설명 (Explanation)

ensuring의 조건 타입으로 컨텍스트 함수 타입 WrappedResult[T] ?=> Boolean을 써요. 따라서 (result == 6) 같은 ensuring 인자에는 타입 WrappedResult[T]의 given이 스코프 안에 있어서 result 메서드로 전달할 수 있어요.

WrappedResult는 새로운(fresh) 타입이에요. 원치 않는 given이 스코프에 들어오는 것을 막기 위해서예요. (컨텍스트 파라미터와 관련된 모든 경우에 좋은 습관이에요.)

WrappedResult는 불투명 타입 별칭이므로 그 값은 박싱될 필요가 없고, ensuring이 확장 메서드로 추가되므로 그 인자도 박싱될 필요가 없어요. 따라서 ensuring의 구현은 손으로 쓸 수 있는 최고의 코드에 가까운 효율을 가져요.

val s =
  val result = List(1, 2, 3).sum
  assert(result == 6)
  result

더 알아보기 (Learn more)

자세한 내용은 (현재는 대체된 다른 문법을 쓰는) 블로그 글을 참고하세요.