Обобщенное программирование — одна из серых, но важных и интересных сторон daScript. “Серость” темы связана с тем, что, во-первых, система типов не очень детально описана в документации, во-вторых — в рассуждениях о типизации можно от практики быстро уйти в дебри академических терминов, в-третьих, тема плохо укладывается в голову C++-программисту.
Поддержка обобщенного программирования в языке, если “на пальцах” — совокупность способов вызывать одну функцию для разных типов.
Перегрузка функций
Перегруженные функции (ad-hoc полиморфизм) — простейший способ определить функцию для двух различных типов
deffunc(a : int)
print("{a}\n")
deffunc(a : float)
print("{a}\n")
[export]
def main
func(1)
func(1.0f)
Константность
Напечатаем тип параметра-аргумента:
deffunc(a : int)
print("{typeinfo(typenamea)}\n")
//Output:
intconst
По умолчанию к типу был добавлен спецификатор const, который не позволяет поменять значение аргумента. Его можно убрать, добавив ключевое слово var:
deffunc(vara: int)
print("{typeinfo(typenamea)}\n")
//Output
int
При выборе перегрузки, константная и неконстантная версия, в отличие от C++, не имеют приоритета друг перед другом и при нахождении двух вариантов функции daScript выдаст ошибку (Правила выбора функции).
var a: int
func(a)
//Output
30304: too many matching functions or generics func
candidates:
func ( a : intconst ) : void at generics.das:3:4//принимает int и int const
func ( a : int -const ) : void at generics.das:9:4//-const читается как "удалить у типа спецификатор const"
Для того, чтобы daScript различил функции, можно добавить спецификатор типа== const (“константность аргумента должна совпадать).
deffunc(a : int ==const)
print("{typeinfo(typenamea)}\n")
deffunc(vara : int ==const)
print("{typeinfo(typenamea)}\n")
[export]
def main
var a: int
func(a)
func(1)
//Output:
int ==const
intconst ==const
Ссылки
В предыдущем примере аргумент передавался по значению, поэтому даже var int не позволяет изменить переданную переменную (меняется значение аргумента, а не оригинальная переменная). Возможно передать аргумент по ссылке:
deffunc(vara : int&)
a = 42
[export]
def main
var a: int
func(a)
print("{a}\n")
//Output: 42
Все непримитивные типы передаются по ссылке, независимо от того, был ли описан аргумент со спецификатором & или без него.
struct A
a : int
deffunc(vararg : A)
arg.a = 42
print("{typeinfo(typenamearg)}\n")
[export]
def main
var a : A
func(a)
print("{a}\n")
//Output
A
[[ 42]]
(причём можно описать 2 перегруженные функции с аргуметами типа A и A&, несмотря на то, что для структур семантически это будет идентичная запись)
При этом, как и с константностью, компилятор не различает приоритета перегрузки функций с аргументом-ссылкой и значением, и выдаёт ошибку неоднозначности разрешения перегрузки.
deffunc(vara: int)
pass
deffunc(vara : 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 — произвольную функцию, которая предварительно проверяет тип аргументов:
Кроме обычных ссылок в 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
defprintColor(c : Color) //same as Color& as c is struct
print("{typeinfo(typenamec)}\n")
defprintColor(c : Color#)
print("{typeinfo(typenamec)}\n")
[export]
def test
let c = [[Color]]
printColor(c)
using() <| $(varc_temp : Color#)
printColor(c_temp)
//Output
tutorial_03::Color const
tutorial_03::Color const#
Если тип нельзя скопировать или переместить, то using не будет не будет создавать временный тип — аргумент и так не сможет покинуть блок
Чаще всего нет необходимости в раздельной обработке обычных и временных ссылок, в этом случае можно добавить к типу аргумента спецификатор implicit:
defprintColor(c:Color implicit)
print("{typeinfo(typenamec)}\n")
//Output
tutorial_03::Color constimplicit
tutorial_03::Color constimplicit
Небольшое отличие в том, как будет трактоваться аргумент:
defprintColor(c:Color implicit) // accepts Color and Color#, a will be treated as Color
defprintColor(c:Color# implicit) // accepts Color and Color#, a will be treated as Color#
Указатели
Как и в C++, указатели — это ссылки, которые могут указывать на null, также имеют чуть другую семантику, что позволяет уже без шаманства иметь перегрузки для значения и указателя.
require daslib/safe_addr
deffunc(vara: int)
print("{typeinfo(typenamea)}\n")
deffunc(vara: int?)
print("{typeinfo(typenamea)}\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).
deffunc(a : int) {}
deffunc(a : float) {}
deffunc(a : int4) {}
deffunc(a : bool) {}
deffunc(a : uint) {}
deffunc(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
structB : A
b : int
deffunc(a : A)
print("{typeinfo(typenamea)}\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
structB : A
b: int
deffunc(vara : A?)
print("a: {typeinfo(typenamea)}\n")
deffunc(varb : B?)
print("b: {typeinfo(typenameb)}\n")
deffunc4(vara,b,c,d: A?)
print("AAAA\n")
deffunc4(vara,b,c: A?; vard: B?)
print("AAAB\n")
deffunc4(vara,b : A?; varc: B?; vard: A?)
print("AABA\n")
deffunc4(vara,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, 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
structB : A
b : int
deffunc(vara : A explicit)
print("{typeinfo(typenamea)}\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
structB : A
b : int
deffunc1(vara : A)
print("a\n")
deffunc2(varb : B)
print("b\n")
defhighOrder(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
defid(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:intconstexplicit ) : intconst
return T
def `id ( T:floatconstexplicit ) : floatconst
return T
def `id ( T:S1 constexplicit ) : S1 const
return T
def `id ( T:S2 constexplicit ) : S2 const
return T
defpublic main
let a:intconst = __::`id(1)
let b:floatconst = __::`id(1f)
let c:S1 const = __::`id([[S1 ]])
let d:S2 const = __::`id([[S2 ]])
По выводу текста сгенерированной программы понятна реализация. Символы подчёркивания перед именем функции __::id означают “взять реализацию функции только из текущего модуля” (линк), идея будет рассмотрена далее.
Большая часть фич, связанных с generic-функциями, связана с тем, чтобы так или иначе задать или использовать информацию о типах.
auto
Определение для id более развернуто выглядит так:
defid(a:auto): auto
return a
Такая форма синтаксиса позволяет задать для каждого из выводимых типов псевдоним, который можно использовать для сравнения типа или получения rtti информации. Несколько примеров:
//print typename
deffunc(a : auto(T))
print("{typeinfo(typenametype<T>)}\n")
//generic sum, a and b must be same type
defsum(a, b : auto(T))
return a + b
Использование типа в качестве аргумента
Можно передать информацию о типе в качестве аргумента шаблона, как обычный auto аргумент.
//generic linear interpolation between int types via cast to float type
deflerpi(a, b : auto(IntType); part : float; tempCastType : auto(CastType))
return IntType(CastType(a) + CastType(b - a) * part)
Для того, чтобы тип не передавался в runtime, существует макрос template, который в compile-time убирает такие аргументы.
Шаблоны для auto
Различные формы ограчений для типов аргументов auto. Примеры из доки
deffoo( a : auto&) // accepts any type, passed by reference
deffoo( a : auto[]) // accepts static array of any type of any size
deffoo( a : array<auto -const>) // matches any array, with non-const elements
//some tests
deffoo(a: tuple<auto; auto; auto>) //tuple of 3 elements, any type
deffoo(a: function<(a : auto) : auto>) //any function with 1 argument
deffoo(a: table<int; auto>) //any tables with int keys
Еще раз приведу ссылку на правила выбора функций при наличии нескольких специализаций и перегрузок.
Контракты
Так же, как и к аргументам обычным функциям, к аргументам generic-функциям могут быть применены контракты, позволяющие в более общем виде описать ограничения для типа аргумента. Именно c generic-функциями видна вся мощь контрактов.
require daslib/contracts
//accept any functions
[expect_any_function(a)]
deffoo(a: auto(T))
print("{typeinfo(typenametype<T>)}\n")
//accept any tuples
[expect_any_tuple(a)]
defbar(a:auto(T))
print("{typeinfo(typenametype<T>)}\n")
[export]
def main
foo(@@(a : int) => a) //function<(a:int const):int const> const
//array_and_notdim([[ int[] 1;2;3 ]]) //int4[2] not allowed
Сумма типов
Еще один способ задать ограничения для типа — перечислить разрешенные типы через символ | (options в доках):
deffoo(vara : int | float | string) //accept int or float or string
deffoo(vara : array<int | float>) //array of int of array of float
deffoo(a : function<(a : auto) : auto> | function<(a, b : auto) : auto>) //accept any function with 1 or 2 arguments
deffoo (a : Bar explicit | Foo) // accept exactly Bar or anything inherited from Foo
deffoo (a : Foo | #) //accept Foo and Foo#, looks like this short syntax only works with #
Порядок проверки соответствия опций — слева направо:
deffoo(vara : auto | int&) { a = 84; }
defbar(vara : 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
deffoo(vars)
s.a = 42//not check if s has field
[export]
def main
var s : S
foo(s) //ok
Ошибка возникнет только в момент инстанциирования foo со структурой, не имеющей поля a. Проверить наличие полей или другую информацию о типе в время компиляции можно с помощью оператора static_if:
daScript распознаёт обычные или generic-функции по синтаксису, но можно также явно обозначить функцию как generic:
options log=true
[generic]
deffunc()
print("hello")
[export]
def main
func()
//Output:
defprivate `func
print("hello",__context__)
// [modify_external]
[export]
defpublic main
__::`func()
В таком случае вызов func будет преобразован в __::func` - вызов версии функции только из текущего модуля. Это используется в некоторых функциях стандартной библиотеки daslib, потому что если компилятор знает, что функция находится в том же модуле, что и вызывающий код, то может её оптимизировать — при AoT-компиляции генериуется не полноценный вызов через ABI (который может вести в другой не-AoT daScript модуль), а прямой вызов, что быстрее.
[instance_function]
С помощью макроса [instance_function] можно попросить явно специализировать generic-функцию с определенными типами:
require daslib/instance_function
deffunc(a : auto(TT))
print("{typeinfo(typenamea )}\n")
[instance_function(func, TT = "int const")]
defprint_int(a) {}
[export]
def main
print_int(1)
Видимость модулей
Для generic функций, которые подразумевают переопределение для новых кастомных типов в других модулях, необходимо добавлять префикс _:: или __::, чтобы обозначить, что функций должна искаться в том модуле, который её вызывает.
//module1.das
[export]
defcall_func(a)
_::func(a) //func will be declared somewhere later
//main.das
require module1
struct S
a: int
deffunc(s: S)
print("{s}\n")
call_func(s) //module1::call_func will see and call main::func()
__:: — подразумевает возможность определения функции только в том же модуле, что и вызывающий код (main) _:: — допускает определение как в том же модуле, что и вызывающий код, так и в других модулях (main, module1 или другие модули)