Функциональное программирование на F# (часть 2)
#dotnet #fsharp #csharp #functional_programmingВ прошлой статье мы написали модуль для расчёта ряда Фибоначчи практически не углубляясь в детали того, что из себя представляет 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
Рассмотрим подробнее, что тут происходит:
- Функция
operationAsInput
принимает на вход функциюop
типаoperation
и значенияx
иy
, а возвращает результат выполнения этой функции дляx
иy
. - Функция
operationAsOutput
возвращает другую функцию типаoperation
. В данном случае умножение двух чисел. - Осуществляется вызов
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# осуществляется внедрение зависимостей и реализуем часто встречающиеся паттерны.