В прошлой статье мы написали модуль для расчёта ряда Фибоначчи практически не углубляясь в детали того, что из себя представляет F# и чем он отличается от кода на C#. В этой статье мы рассмотрим основные принципы функционального программирования, некоторые базовые языковые конструкции F# и исследуем скомпилированный код.

Определение значений (Simple values)

Начнём с самого простого - с определения значений простых типов1.

let hello = "Hello, world!"
let mutable year = 2022

Как видно ниже, значение hello компилируется в статическое свойство с доступом только для чтения, т.е. в функцию, которая не принимает никаких аргументов и всегда возвращает одно и то же значение. Такое поведение является дефолтным для F# и таким образом достигается иммутабельность данных. Если же мы захотим изменять данные, то нужно добавить ключевое слово mutable. Тогда значение скомпилируется в свойство в котором будут и геттер и сеттер.

public static string hello => "Hello, world!";

public static int year { get; set; }

Код на C#, эквивалентный коду на F#, из-за автоматической генерации кода декомпилятора занимает довольно много места. Здесь и далее я опустил некоторые несущественные детали, которые не влияют на смысл. Если интересна полная картина, то можно воспользоваться dotPeek 2 или SharpLab 3.

Определение функций (Function values)

Теперь сделаем из hello функцию, которая принимает один параметр и добавляет его в строку, но реализуем это 5 разными способами.

let hello1 x = $"Hello " + x + "!" 
let hello2 x = $"Hello {x}!"
let hello3 (x: string) = $"Hello {x}!" // explicit type 'string' for an argument 'x'
let hello4 (x: int) = $"Hello {x}!" // explicit type 'int' for an argument 'x'
let hello5 x : string = $"Hello {x}!" // explicit output type

Как мы видим, функции определяются точно также, как и значения - через ключевое слово let. Вообще, это одна из главных особенностей функциональных языков программирования - функции являются объектами первого класса. 4 Это значит, что функции сами являются значениями, т.е. могут быть входными аргументами или возвращаемыми параметрами.

Скомпилированный код hello1 - простой, куда интереснее код с интерполированными строками:

public static string hello1(string x) => string.Concat("Hello ", x, "!");

// hello2 and hello5 have the same method body
public static string hello2<a>(a x)
{
    object[] array = new object[1];
    array[0] = x;
    return PrintfModule.PrintFormatToStringThen(
        new PrintfFormat<string, Unit, string, string, a>(
            "Hello %P()!", array, null));
}

public static string hello3(string x)
{
    object[] array = new object[1];
    array[0] = x;
    return PrintfModule.PrintFormatToStringThen(
        new PrintfFormat<string, Unit, string, string, string>(
            "Hello %P()!", array, null));
}

public static string hello4(int x)
{
    object[] array = new object[1];
    array[0] = x;
    return PrintfModule.PrintFormatToStringThen(
        new PrintfFormat<string, Unit, string, string, int>(
            "Hello %P()!", array, null));
}

Рассмотрим, что тут происходит:

  • Функция hello2 компилируется в обобщённый метод, т.к. мы не указали явно тип входного параметра.
  • Методы hello2 и hello5 по-сути одинаковы (поэтому я их объединил), т.к. F# сам вывел тип для hello2.
  • Для методов hello3 и hello4 явно указан тип x, поэтому метод не обобщённый.
  • Тела методов hello2, hello3 и hello4 выглядят довольно объёмно. Если, например hello4, написать на C#, то скомпилированный код займёт меньше места:
public string hello3(int x) => string.Format("Hello, {0}!", x);

Типы и каррирование функций (Carrying)

Способов определения одних и тех же функций может быть множество. Например, рассмотрим вот эти две функции:

let sum1 x y = x + y 
let sum2 x = fun y -> x + y

Если попытаться явно переписать их на C#, то получилось бы примерно следующее:

// Invocation: int result = sum1(1, 2);
public int sum1(int x, int y) => x + y;

// Invocation: int result = sum2(1)(2);
public Func<int, int> sum2(int x) => (y) =>
{
    return x + y;
};

Для F# функции sum1 и sum2 эквивалентны, но c точки зрения C# - нет, несмотря на то, что результат вызова в обоих случаях будет иметь тип int. Чтобы понять почему так, нужно разобраться, что значит тип в функциональных языках. В объектно-ориентированных языках программирования, когда мы говорим о типах, то мы подразумеваем классы или структуры. В функциональных языках же под типом понимается набор входных и выходных значений.

Сперва может показаться, что на F#, как и на C#, набор входных значений отличается - sum1 принимает два аргумента, а sum2 только один. На самом деле всё не совсем так. В F# одна функция, которая принимает множество параметров, преобразуются в множество функций, которые принимают по одному параметру. Таким образом, функция вида (a, b) -> c, превращается в a -> b -> c. Такой метод преобразования называется каррированием. 5

Благодаря каррированию, функции sum1 и sum2 можно описать одним типом:

type operation = int -> int -> int

Эта странная запись читается слева направо и значит, что функция принимает на вход значение типа int и возвращает функцию, которая, в свою очередь, тоже принимает на вход значение int и возвращает значение int.

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

Как упоминалось ранее, функции в F# - это объекты первого класса. Это значит, что можно записать вот так:

let operationAsInput (op: operation) x y = op x y
let operationAsOutput : operation = fun x -> fun y -> x * y

let result = operationAsInput operationAsOutput 10 5 // result = 50

Рассмотрим подробнее, что тут происходит:

  1. Функция operationAsInput принимает на вход функцию op типа operation и значения x и y, а возвращает результат выполнения этой функции для x и y.
  2. Функция operationAsOutput возвращает другую функцию типа operation. В данном случае умножение двух чисел.
  3. Осуществляется вызов operationAsInput, в которую передаётся функция operationAsOutput и значения 10 и 5.

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

Кстати, при компиляции код будет оптимизирован, поэтому на C# наши декомпилированные функции выглядят немного по-другому:

public static int operationAsInput(
    FSharpFunc<int, FSharpFunc<int, int>> op, int x, int y) => 
        FSharpFunc<int, int>.InvokeFast(op, x, y);

public static int operationAsOutput(int x, int y) => x * y;

Операторы как функции

Как мы знаем, операторы в языках .NET - это методы. Например, оператор + для типа int после компиляции в CIL выглядит примерно так:

.method public hidebysig specialname static int32 op_Addition (
        int32 a,  int32 b) cil managed 
{
    // operator implementation
}

С точки зрения функционального программирования op_Addition - это функция типа int -> int -> int, и это значит, что представленные ниже функции эквивалентны:

// int -> int -> int
let sumInfix x y = x + y
let sumPrefix x y = (+) x y

// int -> int
let incrementInfix x = x + 1
let incrementPrefix = (+) 1

Если посмотреть декомпилированный код на C#, то первые три функции выглядят вполне обычно.

public static int sumInfix(int x, int y) => return x + y;

public static int sumPrefix(int x, int y) => return x + y;

public static int incrementInfix(int x) => return x + 1;

Чего нельзя сказать об incrementPrefix:

  • Во-первых, incrementPrefix компилируется в свойство типа FSharpFunc<int, int>
  • Во-вторых, это свойство возвращает значение поля incrementPrefix@8 из дополнительного класса $_
  • В-третьих, полю $_.incrementPrefix@8 присваивается значение поля Functions.incrementPrefix@8.@_instance в конструкторе типа _$
public static class Functions
{
    internal sealed class incrementPrefix@8 : FSharpFunc<int, int>
    {
        internal static readonly incrementPrefix@8 @_instance = new incrementPrefix@8();
        public override int Invoke(int y) => 1 + y;
    }

    public static FSharpFunc<int, int> incrementPrefix => $_.incrementPrefix@8;
}

internal static class $_
{
    internal static readonly FSharpFunc<int, int> incrementPrefix@8;

    static $_()
    {
        incrementPrefix@8 = Functions.incrementPrefix@8.@_instance;
    }
}

Резюме

В этой статье рассмотрены ключевые принципы функциональных языков программирования:

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

В следующей статье мы продолжим рассматривать синтаксис F#, а также рассмотрим как в F# осуществляется внедрение зависимостей и реализуем часто встречающиеся паттерны.

Ссылки и источники