May 8, 2022

Scala 3: transparent inline with Dynamic

динамическая типизация внутри статической

В скале есть интерфейс Dynamic. От него можно унаследоваться и получить динамическую типизацию - при отсутствии поля в классе компилятор подставит вызов специального метода.

import scala 
import scala.language.dynamics

class MyClass extends Dynamic

val a = MyClass
a.x 
// a.selectDynamic("x")
a.x = 2
// a.updateDynamic("x")(2)

Класс MyClass может быть любым, например таким:

class MyClass extends Dynamic {
  private var x: Int = 0
	
  def selectDynamic(fieldName: String): Int = {
	fieldName match 
	  case "x" => return x 
  }
	
  def updateDynamic(fieldName: String)(value: Int): Unit = {
	fieldName match 
	  case "x" => x = value
  }
}

Конкретно такой класс получился не особо полезным - по поведению он похож на

class MyClass(){var x: Int = 0}

только хуже - во время компиляции мы не увидим ошибок, если обратимся к несуществующему полю, и код упадёт в рантайме. Впрочем, этот код нужен для иллюстрации возожностей языка.

Что, если нам захочется сделать класс с полями разных типов?

class MyClass() {
    var x: Int = 0 
	var b: Boolean = false
}

Если попробуем написать альтернативу этому классу с помощью Dynamic, в качестве принимаемых и возвращаемых типов придётся указывать Any и кастовать к какому-то типу.
Впрочем, у нас тут Scala 3, в которой появились Union types, можно указать в качестве типа поля Int | Boolean и что-то иное типа строки компилятор не даст присвоить.

class MyClass extends Dynamic {  
  private var x: Int = 0  
  private var b: Boolean = false  
  
  def selectDynamic(field: String): Int | Boolean = {  
    field match  
      case "x" => x  
      case "b" => b  
  }  
  
  def updateDynamic(field: String)(value: Int | Boolean): Unit = {  
    field match  
      case "x" =>  
        value match  
          case v: Int => x = v  
      case "y" =>  
        value match  
          case v: Boolean => b = v  
  }  
}

Но появилась ещё проблема - мы всё равно можем попробовать присвоить Boolean в x или Int в b, а потом упадём в рантайме. Такова участь динамической типизации, ничего не поделать.

И тут c ноги врывается transparent inline! Перепишем метод selectDynamic:

import scala.compiletime.error

MyClass { 
    ...
	transparent inline def selectDynamic(inline field: String): Int | Boolean = {  
	  inline field match  
		case "x" => x
		case "b" => b
		case _ => error("unknown field in MyClass")
	}
}

Что будет дальше? В месте вызова

a.x

компилятор подставит вызов функции selectDynamic, пойдёт внутрь, в inline match, найдёт там подходящую строку "x" и заменит всё-всё-всё на простое обращение к полю x с типом Int.

Если быть точным и посмотреть байткод - у MyClass появится метод - геттер

def inline$x(): Int

который будет вызываться напрямую.

Ну либо на это можно смотреть так

val x: Int = a.selectDynamic("x")
val b: Boolean = a.selectDynamic("b")

Возвращаемый тип зависит от аргумента!

Кроме того, если обратиться к несуществующему полю, прямо во время компиляции подставится compiletime.error и компилятор покажет ошибку.

Поздравляю, мы снова вернулись к статической типизации. Какие выводы можно сделать?

  • transparent inline - очень мощный инструмент, позволяющий гибко работать с типами. В scala 2.0 такое невозможно.
  • Нет строго дуализма между runtime и compiletime преобразованиями, гибкость scala позволяет в какой-то мере заменить первое на второе. В С++ похожая ситуация.

Для полноты картины покажу, как можно переписать второй метод:

transparent inline def updateDynamic(inline field: String)(inline value: Int | Boolean): Unit = { 
  inline field match  
    case "x" =>  
      inline value match  
        case v: Int => x = v  
        case _ => error("should be int")  
    case "y" =>  
      inline value match  
        case v: Boolean => b = v  
        case _ => error("should be boolean")  
    case _ => error("unknown field")  
}

Посмотрите на переход от динамической типизации обратно к статической при помощи более "умного" компилятора! Что-то подобное можно попробовать в динамических языках типа Python, чтобы ловить ошибки как можно раньше. Не все, но какую-то часть.

Пример кода выше не очень практичен - можно сделать обычный класс с полями x: Int, b:Boolean и он будет прекрасно работать. В классе c Dynamic уже известные поля можно заменить на реальные поля и это тоже будет прекрасно работать, компилятор будет обращаться напрямую к ним вместо вызова selectDynamic:

class MyDynamic extends Dynamic{
    var x: Double = 0
	var b: Boolean = 0
	def selectDynamic(...) ...
}

В общем, код выше дублирует базовые возможности языка, бесполезен сам по себе, но, надеюсь, полезен для иллюстрации.

Для чего использовать transparent inline в реальном коде - не знаю. Люди как-то жили без этой возможности и прекрасно решали свои задачи. Но я точно уверен, что часть этих задач теперь можно решить более гибко и красиво.