Spiiin's blog

daScript: обобщенное программирование

Обобщенное программирование — одна из серых, но важных и интересных сторон daScript. “Серость” темы связана с тем, что, во-первых, система типов не очень детально описана в документации, во-вторых — в рассуждениях о типизации можно от практики быстро уйти в дебри академических терминов, в-третьих, тема плохо укладывается в голову C++-программисту.

Поддержка обобщенного программирования в языке, если “на пальцах” — совокупность способов вызывать одну функцию для разных типов.

Перегрузка функций

Перегруженные функции (ad-hoc полиморфизм) — простейший способ определить функцию для двух различных типов

def func(a : int)
    print("{a}\n")
def func(a : float)
    print("{a}\n")
[export]
def main
    func(1)
    func(1.0f)

Константность

Напечатаем тип параметра-аргумента:

def func(a : int)
    print("{typeinfo(typename a)}\n")
//Output:
int const

По умолчанию к типу был добавлен спецификатор const, который не позволяет поменять значение аргумента. Его можно убрать, добавив ключевое слово var:

def func(var a: int)
    print("{typeinfo(typename a)}\n")
//Output
int

При выборе перегрузки, константная и неконстантная версия, в отличие от C++, не имеют приоритета друг перед другом и при нахождении двух вариантов функции daScript выдаст ошибку (Правила выбора функции).

var a: int
func(a)
//Output
30304: too many matching functions or generics func
candidates:
        func ( a : int const ) : void at generics.das:3:4 //принимает int и int const
        func ( a : int -const ) : void at generics.das:9:4 //-const читается как "удалить у типа спецификатор const"

Для того, чтобы daScript различил функции, можно добавить спецификатор типа == const (“константность аргумента должна совпадать).

def func(a : int ==const)
    print("{typeinfo(typename a)}\n")

def func(var a : int ==const)
    print("{typeinfo(typename a)}\n")

[export]
def main
    var a: int
    func(a)
    func(1)
//Output:
int ==const
int const ==const

Ссылки

В предыдущем примере аргумент передавался по значению, поэтому даже var int не позволяет изменить переданную переменную (меняется значение аргумента, а не оригинальная переменная). Возможно передать аргумент по ссылке:

def func(var a : int&)
    a = 42

[export]
def main
    var a: int
    func(a)
    print("{a}\n")
//Output: 42

Все непримитивные типы передаются по ссылке, независимо от того, был ли описан аргумент со спецификатором & или без него.

struct A
    a : int

def func(var arg : A)
    arg.a = 42
    print("{typeinfo(typename arg)}\n")

[export]
def main
    var a : A
    func(a)
    print("{a}\n")
//Output
A
[[ 42]]

(причём можно описать 2 перегруженные функции с аргуметами типа A и A&, несмотря на то, что для структур семантически это будет идентичная запись)

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

def func(var a: int)
    pass
def func(var a : int&)
    pass

[export]
def main
    func(1) //ok
    var a: int
    func(a) //30304: too many matching functions or generics func
            //candidates:
            //func ( a : int -const ) : void at generics.das:1:4
            //func ( a : int& -const ) : void at generics.das:3:4

Контракты

Макросы работают раньше разрешения перегрузки, что позволяет реализовать паттерн contracts — произвольную функцию, которая предварительно проверяет тип аргументов:

require daslib/contracts

[!expect_ref(arg)]
def func(var arg : int)
    print("{typeinfo(typename arg)}\n")

[expect_ref(arg)]
def func(var arg : int&)
    print("{typeinfo(typename arg)}\n")

[export]
def main
    var a: int
    func(a)
    func(1)
//Output:
int //must be int&
int

https://github.com/GaijinEntertainment/daScript/blob/master/examples/test/misc/contracts_example.das
вывод typeinfo, кажется, содержит баг

Временные ссылки

Кроме обычных ссылок в daScript есть временные ссылки, которые позволяют работать с объектами из C++-кода внутри блоков. Временная ссылка доступна только внутри блока, и не может быть сохранена вне его (но может быть передана в другую функцию, принимающую временные объекты).

Рассмотрим для примера C++ тип Color из туториала к daScript. Для него создаётся daScript-обёртка, в которую можно добавить декларацию конструктора и инициализатора с помощью паттерна using — в этом случае можно создать временную ссылку на тип, которая будет доступна только внутри блока:

//cpp
Module_Tutorial03() : Module("tutorial_03") { // module name, when used from das file
ModuleLibrary lib;
...
addCtorAndUsing<Color>(*this, lib, "Color", "Color");
}
//das

require tutorial_03

def printColor(c : Color) //same as Color& as c is struct
    print("{typeinfo(typename c)}\n")

def printColor(c : Color#)
    print("{typeinfo(typename c)}\n")

[export]
def test
    let c = [[Color]]
    printColor(c)
    using() <| $(var c_temp : Color#)
        printColor(c_temp)
//Output
tutorial_03::Color const
tutorial_03::Color const#

Если тип нельзя скопировать или переместить, то using не будет не будет создавать временный тип — аргумент и так не сможет покинуть блок

Чаще всего нет необходимости в раздельной обработке обычных и временных ссылок, в этом случае можно добавить к типу аргумента спецификатор implicit:

def printColor(c:Color implicit)
    print("{typeinfo(typename c)}\n")
//Output
tutorial_03::Color const implicit
tutorial_03::Color const implicit

Небольшое отличие в том, как будет трактоваться аргумент:

def printColor(c:Color implicit)    // accepts Color and Color#, a will be treated as Color
def printColor(c:Color# implicit)   // accepts Color and Color#, a will be treated as Color#

Указатели

Как и в C++, указатели — это ссылки, которые могут указывать на null, также имеют чуть другую семантику, что позволяет уже без шаманства иметь перегрузки для значения и указателя.

require daslib/safe_addr

def func(var a: int)
    print("{typeinfo(typename a)}\n")

def func(var a: int?)
    print("{typeinfo(typename a)}\n")

[export]
def main
    var a: int
    var a_ptr: int? = safe_addr(a)
    func(a)
    func(a_ptr)
//Output
int
int?

Приведение базовых типов

Базовые типы не приводятся друг другу неявно, требуется явный вызов конструктора типа (Explicit is better than implicit).

def func(a : int) {}
def func(a : float) {}
def func(a : int4) {}
def func(a : bool) {}
def func(a : uint) {}
def func(a : int64) {}

[export]
def main
    func(1) //int
    func(float(1)); func(1.0f) //float
    func(int4(1)) //int4
    func(true) //bool
    func(uint(1)); func(1u); func(0x1) //uint
    func(int64(1)); func(1l) //int64

Приведение классов/структур

Для типов, поддерживающих наследование, неявно выполняется приведение указателей и ссылок от дочернего к родительскомму типу (LSP).

struct A
    a : int

struct B : A
    b : int

def func(a : A)
    print("{typeinfo(typename a)}\n")

[export]
def main
    var a : A
    var b : B
    func(a)
    func(b)
//Output:
A const
A const

Приведение типов структур (cast/upcast/reinterpret):

var a : A
    var b : B

    var refA : A& = a
    var refB : B& = b

    //downcast, safe
    refA = cast<A&> refB
    //upcase, unsafe
    unsafe
        refB = upcast<B&> refA
    //reinterpret cast, VERY unsafe, can cast any
    unsafe
        refA = reinterpret<A&>(1) //will crash

При выборе перегрузки функции выбирается та, для которой нужно выполнить наименьшее количество преобразований (при равном количестве daScript выдаст ошибку неоднозначности выбора)

struct A
    a : int

struct B : A
    b: int

def func(var a : A?)
    print("a: {typeinfo(typename a)}\n")

def func(var b : B?)
    print("b: {typeinfo(typename b)}\n")

def func4(var a,b,c,d: A?)
    print("AAAA\n")

def func4(var a,b,c: A?; var d: B?)
    print("AAAB\n")

def func4(var a,b : A?; var c: B?; var d: A?)
    print("AABA\n")

def func4(var a,b,c,d: B?)
    print("BBBB\n")

[export]
def main
    //simple cases
    var refA = new A()
    func(refA) //a: A?
    var refB = new B()
    func(refB) //b: B?
    var refAB = cast<A?> new B()
    func(refAB) //a: A?

    //advanced cases
    func4(refA, refA, refA, refA) //shortest LSP to AAAA = 0
    func4(refA, refA, refA, refB) //shortest LSP to AAAB = 0
    //func4(refA, refA, refB, refB) //shortest LSP to AAAB/AABA = 1, conflict error
    func4(refA, refB, refB, refA) //shortest LSP to AABA = 1
    func4(refB, refB, refB, refB) //shortest LSP to BBBB = 0

explicit

Для того, чтобы отключить LSP приведение типа аргумента, можно добавить ключевое слово explicit. Так

struct A
    a : int

struct B : A
    b : int

def func(var a : A explicit)
    print("{typeinfo(typename a)}\n")

[export]
def main
    var a : A
    var b : B
    func(a)   //A
    //func(b) //invalid argument a (0). expecting A explicit -const, passing B& -const

Приведение generic-типов

В документации не описана работа с generic-типами (и не дано общее определение для них, также пока отсутствует возможность создания своих типов), но поиском по коду находятся такие встроенные типы (исключая те, которые связаны с оператором typeinfo и кастами):

Функциональные объекты:
block
function
lambda

Коллекции:
array
table<key>
table<key, value>

iterator
generator
smart_ptr
tuple
variant

Для таких типов, возможно явное LSP-приведение для типов их аргументов (ковариантность). Пример для функций:

struct A
    a : int

struct B : A
    b : int

def func1(var a : A)
    print("a\n")

def func2(var b : B)
    print("b\n")

def highOrder(func: function<(var a:A):void>)
    invoke(func, [[B]])

[export]
def main
    highOrder(@@func1)
    highOrder(cast<function<(var a:A):void>> @@func2) //возможно привести тип function<(var b:B):void> к function<(var a:A):void>

Generic-функции

Вернёмся к самому первому примеру — если мы хотим написать функцию, семантически одинаково обрабатывающую различные типы (например, выводящую значение типа с помощью функции print) для типов. Чтобы не реализовывать её для каждого нового типа, в языках программирования используется понятие generic-функций, которые могут производить конкретные функции для новых типов автоматически.

Обзор реализаций в языках.

Шаблонные функции в C++ производят код конкретных функций на уровне текста, который отдаётся компилятору (если не ошибаюсь, компилятор visual studio в этом плане действительно генерирует полные копии, не остлеживаю возможных повторов, чтобы иметь больше простора для частных оптимизаций функции под конкретные типы, а clang чуть раньше начинает отслеживать потенциально идентичные реализации для экономии памяти).

Другой возможный вариант реализации в Java — “изображать” generic на высоком уровне для контроля типов, но оставлять одну реализацию (все объекты передаются по ссылке, добавляется overhead при работе с value-типами по боксингу/анбоксингу в обёртку).

Третий путь из C# — добавить поддержку generic-функций в виртуальную машину, в этом случае возможна комбинированная реализация — value-типы получают свои сгенерированные копии функций, а reference-типы — общую функцию. Также возможно инстанцировать новые версии функций в runtime. daScript близок к такому типу реализации generic-функций.

Автоматический вывод типов

Если не указан тип аргумента функции, daScript выводит его автоматически, пример функции id принимающей аргумент любого типа и возвращающий его:

options log=true, optimize=false

struct S1
    a: int
struct S2
    a: int

def id(T)
    return T

[export]
def main
    let a = id(1)
    let b = id(1.0f)
    let c = id([[S1]])
    let d = id([[S2]])
//Output

def `id ( T:int const explicit ) : int const
        return T

def `id ( T:float const explicit ) : float const
        return T

def `id ( T:S1 const explicit ) : S1 const
        return T

def `id ( T:S2 const explicit ) : S2 const
        return T

def public main
        let a:int const = __::`id(1)
        let b:float const = __::`id(1f)
        let c:S1 const = __::`id([[S1 ]])
        let d:S2 const = __::`id([[S2 ]])

По выводу текста сгенерированной программы понятна реализация. Символы подчёркивания перед именем функции __::id означают “взять реализацию функции только из текущего модуля” (линк), идея будет рассмотрена далее.

Большая часть фич, связанных с generic-функциями, связана с тем, чтобы так или иначе задать или использовать информацию о типах.

auto

Определение для id более развернуто выглядит так:

def id(a:auto): auto
    return a

Такая форма синтаксиса позволяет задать для каждого из выводимых типов псевдоним, который можно использовать для сравнения типа или получения rtti информации. Несколько примеров:

//print typename
def func(a : auto(T))
    print("{typeinfo(typename type<T>)}\n")

//generic sum, a and b must be same type
def sum(a, b : auto(T))
    return a + b

Использование типа в качестве аргумента

Можно передать информацию о типе в качестве аргумента шаблона, как обычный auto аргумент.

//generic linear interpolation between int types via cast to float type
def lerpi(a, b : auto(IntType); part : float; tempCastType : auto(CastType))
    return IntType(CastType(a) + CastType(b - a) * part)
print("{lerpi(int2(0, 0), int2(4, 4), 0.5f, type<float2>)}\n") // (2,2)
print("{lerpi(int3(1, 2, 3), int3(2, 4, 7), 0.5f, type<float3>)}\n") // (1,3,5)

Для того, чтобы тип не передавался в runtime, существует макрос template, который в compile-time убирает такие аргументы.

Шаблоны для auto

Различные формы ограчений для типов аргументов auto. Примеры из доки

def foo( a : auto&)           // accepts any type, passed by reference
def foo( a : auto[])          // accepts static array of any type of any size
def foo( a : array<auto -const>)  // matches any array, with non-const elements
//some tests
def foo(a: tuple<auto; auto; auto>) //tuple of 3 elements, any type
def foo(a: function<(a : auto) : auto>) //any function with 1 argument
def foo(a: table<int; auto>) //any tables with int keys

Еще раз приведу ссылку на правила выбора функций при наличии нескольких специализаций и перегрузок.

Контракты

Так же, как и к аргументам обычным функциям, к аргументам generic-функциям могут быть применены контракты, позволяющие в более общем виде описать ограничения для типа аргумента. Именно c generic-функциями видна вся мощь контрактов.

require daslib/contracts

//accept any functions
[expect_any_function(a)]
def foo(a: auto(T))
    print("{typeinfo(typename type<T>)}\n")

//accept any tuples
[expect_any_tuple(a)]
def bar(a:auto(T))
    print("{typeinfo(typename type<T>)}\n")

[export]
def main
    foo(@@(a : int) => a)                       //function<(a:int const):int const> const
    foo(@@(a : int; b: float) => "hello world") //function<(a:int const;b:float const):string const> const
    bar([[auto 1 ,2.0f, "test"]])               //tuple<int;float;string> const
    bar([[auto 1, 1]])                          //tuple<int;int> const

Контракты для одного аргумента могут комбинироваться с помощью операторов !, &&, || и ^^

require daslib/contracts

[expect_any_function(arg) || expect_any_tuple(arg)]
def func_or_tuple(var arg : auto)
    print("{typeinfo(typename arg)}\n")

//expect_any_array разрешает любые массивы, expect_dim - статические массивы
[expect_any_array(arg) && !expect_dim(arg)]
def array_and_notdim(var arg : auto)
    print("{typeinfo(typename arg)}\n")

[export]
def main
    func_or_tuple(@@(a : int) => a)
    func_or_tuple([[auto 1, 2.0, "3"]])

    array_and_notdim([{ int[] 1;2;3 }]) //array<int> allowed
    //array_and_notdim([[ int[] 1;2;3 ]]) //int4[2] not allowed

Сумма типов

Еще один способ задать ограничения для типа — перечислить разрешенные типы через символ | (options в доках):

def foo(var a : int | float | string) //accept int or float or string
def foo(var a : array<int | float>) //array of int of array of float
def foo(a : function<(a : auto) : auto> | function<(a, b : auto) : auto>) //accept any function with 1 or 2 arguments
def foo (a : Bar explicit | Foo)   // accept exactly Bar or anything inherited from Foo
def foo (a : Foo | #) //accept Foo and Foo#, looks like this short syntax only works with #

Порядок проверки соответствия опций — слева направо:

def foo(var a : auto | int&) { a = 84; }
def bar(var a : int& | auto) { a = 42; }

[export]
def main
    var a: int
    foo(a) // match foo(auto)
    print("{a}\n") // a == 0
    bar(a) // match bar(int&)
    print("{a}\n") // a == 42

static_if

Проверка наличия методов или полей структуры выполняется в момент инстанцирования generic-функции

struct S
    a : int

def foo(var s)
    s.a = 42 //not check if s has field

[export]
def main
    var s : S
    foo(s) //ok

Ошибка возникнет только в момент инстанциирования foo со структурой, не имеющей поля a. Проверить наличие полей или другую информацию о типе в время компиляции можно с помощью оператора static_if:

struct S
    a : int
struct T
    a : float4

def foo(var s)
    static_if typeinfo(has_field<a> s) && (typeinfo(typename s.a) == typeinfo(typename type<int -const>))
        s.a = 42

var s : S
foo(s) //ok
var t: T
foo(t) //also ok, but do nothing

Вызываемые макросы

Более сложные конструкции вроде “вызвать конструктор того же типа, что и поле структуры s.a можно выразить с помощью макросов

//generics macro
module generics_macro shared private

[call_macro(name="convert_to")]  // convert_to(convertType, arg)
class ApplyMacro : AstCallMacro
    //! convert_to("float4", 42) -> float4(42)
    def override visit ( prog:ProgramPtr; mod:Module?; var expr:smart_ptr<ExprCallMacro> ) : ExpressionPtr
        var exprConstStr <- unsafe(reinterpret< smart_ptr<ast::ExprConstString>&> expr.arguments[0])
        var call <- new [[ExprCall() name:=exprConstStr.value, at=expr.at]]
        emplace_new(call.arguments, clone_expression(expr.arguments[1]))
        return <- call

//
require generics_macro

def foo(var s)
    static_if typeinfo(has_field<a> s)
        static_if typeinfo(has_field<a> s)
            static_if typeinfo(typename s.a) == typeinfo(typename type<int -const>)
                s.a = 42
            else
                s.a = convert_to(typeinfo(typename s.a), 42) // --> s.a = float4(42)

var t : T
foo(t)
print("{t}\n")

//Output:
[[ 42,42,42,42]]

[generic]

daScript распознаёт обычные или generic-функции по синтаксису, но можно также явно обозначить функцию как generic:

options log=true

[generic]
def func()
    print("hello")

[export]
def main
    func()

//Output:

def private `func
        print("hello",__context__)

// [modify_external]
[export]
def public main
        __::`func()

В таком случае вызов func будет преобразован в __::func` - вызов версии функции только из текущего модуля. Это используется в некоторых функциях стандартной библиотеки daslib, потому что если компилятор знает, что функция находится в том же модуле, что и вызывающий код, то может её оптимизировать — при AoT-компиляции генериуется не полноценный вызов через ABI (который может вести в другой не-AoT daScript модуль), а прямой вызов, что быстрее.

[instance_function]

С помощью макроса [instance_function] можно попросить явно специализировать generic-функцию с определенными типами:

require daslib/instance_function

def func(a : auto(TT))
    print("{typeinfo(typename a )}\n")

[instance_function(func, TT = "int const")]
def print_int(a) {}

[export]
def main
    print_int(1)

Видимость модулей

Для generic функций, которые подразумевают переопределение для новых кастомных типов в других модулях, необходимо добавлять префикс _:: или __::, чтобы обозначить, что функций должна искаться в том модуле, который её вызывает.

//module1.das
[export]
def call_func(a)
    _::func(a) //func will be declared somewhere later

//main.das
require module1

struct S
    a: int

def func(s: S)
    print("{s}\n")

call_func(s) //module1::call_func will see and call main::func()

__:: — подразумевает возможность определения функции только в том же модуле, что и вызывающий код (main)
_:: — допускает определение как в том же модуле, что и вызывающий код, так и в других модулях (main, module1 или другие модули)