Функции

Функции — ключевой компонент Ficus, как и любого функционального языка программирования (что ясно из названия). Часто говорится, что функции в функциональных языках являются “первоклассными гражданами”. Это подразумевает:

  • Рекурсивные функции часто применяются вместо циклов, причём компилятор обеспечивает эффективность и гарантирует, что хвостовая рекурсия превращается в простой цикл.
  • Функции могут быть определены внутри других функций и обращаться к локальным переменным внешнего контекста.
  • Функции могут быть определены в любом месте, где разрешено выражение, передаваться другим функциям в качестве аргументов (так называемые анонимные или лямбда-функции).
  • Функции могут возвращаться из других функций. Не путайте это с указателями на глобальные функции, которые могут вернуть любые языки, включая C.

Всё это поддерживается в Ficus, как мы вскоре увидим.

Синтаксис объявления функции выглядит следующим образом:

  1. Функция, тело которой — единственное выражение:
fun func_name(arg1: T1, arg2: T2, ..., argn: Tn)
   [: optional_ret_type] = body_exp
  1. Функция, тело которой — блок кода:
fun func_name(arg1: T1, arg2: T2, ..., argn: Tn)
   [: optional_ret_type] { exprs ... }

(Есть ещё одна форма функции, ориентированная на сопоставление с образцом, которая рассматривается в разделе Сопоставление с образцом.)

Приведём несколько примеров функций:

// Классическая реализация факториала с помощью рекурсии
fun fact(n: int): int = if n <= 1 {1} else {n*fact(n-1)}

// Более эффективный вариант факториала с хвостовой рекурсией
fun fact_fast(n: int)
{
    // Легко определять функции внутри других функций
    fun fact_(n: int, p: int) =
        if n <= 1 {p} else {fact_(n-1, n*p)}
    fact_(n, 1)
}

// Вычислить площадь треугольника в двумерном пространстве
// Удобно группировать параметры с помощью кортежей
fun triangle_area((x0, y0): (float*2),
                  (x1, y1): (float*2),
                  (x2, y2): (float*2)) =
    0.5f*abs((x1 - x0)*(y2 - y0) - (y1 - y0)*(x2 - x0))

// Перегрузка функции для трёхмерных треугольников
fun triangle_area((x0, y0, z0): (float*3),
                  (x1, y1, z1): (float*3),
                  (x2, y2, z2): (float*3))
{
    val u = (x1, y1, z1) - (x0, y0, z0)
    val v = (x2, y2, z2) - (x0, y0, z0)
    0.5f*sqrt((u.1*v.2 - u.2*v.1)**2 +
              (u.0*v.2 - u.2*v.0)**2 +
              (u.0*v.1 - u.1*v.0)**2)
}

Допускается существование более одной функции с данным именем в пределах одной области видимости, если функции имеют разное количество или типы параметров.

Передача аргументов и получение результатов

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

В Ficus компилятор применяет единый метод передачи аргументов каждого конкретного типа:

  • Числа, bool, char, cptr, списки 't list и рекурсивные варианты (см. раздел Варианты) передаются по значению.
  • Остальные параметры передаются по указателю.

Проще говоря, если аргумент является скаляром или уже указателем (например, cptr, 't list и рекурсивные варианты — это указатели), он передаётся по значению, иначе — по указателю. Этот подход лёгок для запоминания и эффективен, так как Ficus никогда не использует сложных операций копирования при передаче аргументов.

Существует максимум один выходной аргумент, который является возвращаемым значением функции. Если нужно вернуть несколько значений, следует воспользоваться кортежем. На уровне C-кода (см. раздел Совместимость с C) все результаты возвращаются через указатель, даже скалярные значения, кроме случая, когда функция написана на C/C++ и помечена как @nothrow, так как код возврата сгенерированной C-функции используется для обработки исключений (см. раздел Исключения).

Параметры функций всегда неизменяемы, но есть важное уточнение:

  • Параметр функции воспринимайте как величину, определённую с помощью val.
  • Это значит, что если параметр является ссылкой или массивом, вы не сможете задать его значение, но можете модифицировать его содержимое:
fun threshold(img: uint8 [,], t: int) {
   // Ошибка: невозможно назначить значение параметру
   if t < 0 {t = 0}
   for pix@(y, x) <- img {
       // Нормально: массив изменяется на месте
       if int(pix) < t {img[y, x] = 0u8}
   }
}

Лямбда-функции

Некоторые алгоритмы высшего порядка принимают пользовательские функции в качестве параметров (примерно соответствующие “колбекам” в терминологии C/C++). Часто удобно конструировать такие колбеки динамически:

fun integrate(a: double, b: double, n: int, f: double->double)
{
    val h = (b - a)/n
    (fold sum = 0., left = f(a) for i <- 0:n {
        val right = f(a + (i+1)*h)
        (sum + (left + right)*h*0.5, right)
    }).0
}

println(integrate(0., 2*M_PI, 100, fun (x) {sin(x)**2})) // выводит 3.1415...

val arr = [1, 100, 10, 30, 15]
val sorted_idx = [for i<-0:size(arr) {i}]
// Косвенно сортировать массив путём перестановки индексов
// Результат: [0, 2, 4, 3, 1]
sort(sorted_idx, fun (i, j) {arr[i] < arr[j]})

Пример демонстрирует анонимные функции или лямбда-функции, которые обычно записываются так:

fun (arg1 [: T1], arg2 [: T2], ..., argn[: Tn])
    [: optional_ret_type] {exprs...}

Основные отличия от обычной функции:

  • Имя функции пропускается. Однако можно объявить значение или переменную, инициализировать её лямбда-функцией и затем вызывать эту функцию по имени.
  • Форма = не применяется, используется {}.
  • Определение типов аргументов необязательно, так как лямбда-функции обычно имеют небольшую область видимости, и их типы аргументов часто можно вывести из контекста использования. В частности, стандартная функция sort, использованная в примере выше, объявлена так:
fun sort(arr: 't [], less_than: ('t, 't)->bool) {...}

То есть функция сравнения должна принимать два аргумента, чей тип совпадает с типом элементов массива. Если передать ей массив целых чисел, компилятор выведет, что функция сравнения должна иметь тип (int, int)->bool.

Замыкания

Когда функция объявляется внутри другой функции, она может обращаться к значениям из внешнего контекста. Если вложенная функция возвращается, захваченные внешние значения сохраняются. Такие функции называются замыканиями, и это мощный инструмент. Приведём пример генератора случайных чисел с использованием замыканий:

import Sys

fun make_coin()
{
    val rng = RNG(uint64(Sys.tick_count()))
    // Немного прогреть генератор случайных чисел
    val _ = fold s = 0u64 for i <- 0:10 {s ^ rng.next()}
    fun () { if bool(rng) {"орёл"} else {"решка"} }
}
val coin1 = make_coin()
val coin2 = make_coin()
for i <- 0:100 {println(f"{i}. {coin1()}, {coin2()}")}

Здесь мы создали фабрику монет, которая производит различные генераторы случайных чисел. Каждый генератор сохраняет своё внутреннее состояние и может использоваться абсолютно независимо от остальных, без использования глобальных переменных.

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

fun make_deriv(f: double->double, delta: double) {
    fun (x: double) {(f(x + delta) - f(x - delta))/(delta*2)}
}
val d_sin = make_deriv(sin, 0.001)
for i <- 0:10 {
    val x = i/3.
    val err = abs(d_sin(x) - cos(x))
    println(f"{x}: abs(deriv_approx-deriv_precise)={err}")
}

Неявное преобразование типов аргументов

В некоторых языках, таких как C/C++ или Java, фактические типы аргументов, передаваемых функции, могут неявно приводиться к формальным параметрам. В Ficus такого поведения нет — если функция принимает аргумент типа double, вы не можете передать значение типа float, несмотря на то, что преобразование можно выполнить без потери точности. Такое жёсткое правило существенно облегчает выбор правильной перегрузки функции и помогает выявлять ошибки на этапе компиляции, жертвуя небольшим удобством.

Функции с именованными параметрами

Многие функции обычно имеют один-два параметра. Но существуют сложные алгоритмы с большим количеством параметров, и вы можете захотеть предоставить полный контроль пользователям над всеми этими параметрами. Одновременно с этим важно поддерживать ясность API, и для этого хочется:

  1. Сделать понятно, какому параметру присвоено данное значение. Иногда для этого применяют комментарии, но они не гарантируют своевременность изменений.
  2. Предоставить стандартные значения для редко используемых параметров и флагов.

В Ficus обе цели достигаются с помощью так называемых именованных параметров, отличающихся от позиционных, которые идентифицируются своим положением в списке параметров. Функция может использовать либо только позиционные, либо только именованные параметры, либо комбинацию тех и других. В последнем случае именованные параметры обязательно следуют за позиционными:

fun funcname([pos_arg1: Tp1, pos_arg2: Tp2, ...]
             [~named_arg1: Tn1 [=defval1],
              ~named_arg2: Tn2 [=defval2],
              ...]) [ : optional_rettype ]
{
   ...
}

Каждый именованный параметр:

  • Начинается с символа ~.
  • Может иметь значение по умолчанию. (Замечание: в настоящее время только литералы могут выступать значениями по-умолчанию.)

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

Пример:

type detection_t = {
    x: int; y: int;
    width: int; height: int;
    confidence: double
}
fun create_face_detector(
       deep_model_topo_filename: string,
       deep_model_weights_filename: string,
       image_size: int = 300,
       channel_order: string = "RGB",
       meanvalue_r: double = 0.,
       meanvalue_g: double = 0.,
       meanvalue_b: double = 0.,
       pix_scale: double = 1.): (uint8*3) [,] -> detection_t vector
{
    // Загрузить модель, запомнить параметры
    ...
    // Лямбда-функция, выполняющая сеть и возвращающая вектор распознанных лиц
    fun (image: (uint8*3)[,]) {
        ...
    }
}
val detector = create_face_detector(
      "mymodel.txt", "mymodel.weights",
       meanvalue_r = 128.,
       meanvalue_g = 128.,
       meanvalue_b = 128.,
       scale = 1./255) // Оставить размер изображения и порядок каналов без изменений
...
val faces = detector(myimage)

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

Ранний выход с помощью return

Ficus поддерживает оператор return, позволяющий выйти из функции раньше с возможным возвращением значения. Подобно любому другому оператору управления потоком в Ficus, return является выражением. Само выражение return имеет тип void, вне зависимости от того, возвращает оно значение или нет.

fun foo(arg1: t1, ..., argn: tn) {
    ...
    if some_expr { return some_value }
    ...
    match another_expr {
    ...
    | pattern => return another_value
    ... // остальные действия должны иметь тип void
    }
    final_ret_value // финальное выражение
}

В данном примере значения some_value, another_value и final_ret_value должны иметь одинаковый тип. Если функция имеет тип void, то все операторы return в ней должны применяться без возвращения значения.