Функциональное мышление. Часть 9 +14



Это уже 9 часть серии статей по функциональному программированию на F#! Уверен, на Хабре существует не очень много настолько длинных циклов. Но мы не собираемся останавливаться. Сегодня расскажем про вложенные функции, модули, пространства имен и смешивание типов и функций в модулях.






Теперь Вы знаете как определять функции, но как их организовать?


В F# возможны три варианта:


  • функции могут быть вложены в другие функции.
  • на уровне приложения функции верхнего уровня группируются по "модулям".
  • или же можно придерживаться объектно-ориентированного подхода и прикреплять функции к типам в качестве методов.

В этой статье рассмотрим первые два способа, а оставшийся — в следующей.


Вложенные функции


В F# можно определять функции внутри других функций. Это хороший способ инкапсулировать вспомогательные функции, которые необходимы лишь для основной функции и не должны быть видны снаружи.


В примере ниже add вложен в addThreeNumbers:


let addThreeNumbers x y z  =

    // создаём вложенную вспомогательную функцию
    let add n =
       fun x -> x + n

    // используем вспомогательную функцию
    x |> add y |> add z

addThreeNumbers 2 3 4

Вложенные функции могут получить доступ к родительским параметрам напрямую, потому-что они находятся в её области видимости.
Так, в приведенном ниже примере вложенная функцияprintError не нуждается в параметрах, т.к. она может получить доступ к n и max напрямую.


let validateSize max n  =

    // создаём вложенную вспомогательную функцию без параметров
    let printError() =
        printfn "Oops: '%i' is bigger than max: '%i'" n max

    // используем вспомогательную функцию
    if n > max then printError()

validateSize 10 9
validateSize 10 11

Очень распространённым паттерном является основная функция, определяющая вложенную рекурсивную вспомогательную функцию, которая вызывается с соответствующими начальными значениями.
Ниже приведён пример такого кода:


let sumNumbersUpTo max =

    // рекурсивная вспомогательная функция с аккумулятором
    let rec recursiveSum n sumSoFar =
        match n with
        | 0 -> sumSoFar
        | _ -> recursiveSum (n-1) (n+sumSoFar)

    // вызываем вспомогательную функцию с начальными значениями
    recursiveSum max 0

sumNumbersUpTo 10

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


Пример того как не надо делать:


// wtf, что делает эта функция?
let f x =
    let f2 y =
        let f3 z =
            x * z
        let f4 z =
            let f5 z =
                y * z
            let f6 () =
                y * x
            f6()
        f4 y
    x * f2 x

Модули


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


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


Определение модуля содержащего две функции:


module MathStuff =

    let add x y  = x + y
    let subtract x y  = x - y

Если открыть этот код в Visual Studio, то при наведении на add можно увидеть полное имя add, которое в действительности равно MathStuff.add, как будто MastStuff был классом, а add — методом.


В действительности именно это и происходит. За кулисами F# компилятор создает статический класс со статическими методами. C# эквивалент выглядел бы так:


static class MathStuff
{
    static public int add(int x, int y)
    {
        return x + y;
    }

    static public int subtract(int x, int y)
    {
        return x - y;
    }
}

Осознание того, что модули — это всего всего лишь статические классы, а функции — статические методы, даст хорошее понимание того, как модули работают в F#, поскольку большинство правил, применимых к статическим классам, применимо также и к модулям.


И так же, как в C# каждая отдельно стоящая функция должна быть частью класса, в F# каждая отдельно стоящая функция должна быть частью модуля.


Доступ к функциям за пределами модуля


Если есть необходимость обратиться к функции из другого модуля, можно ссылаться на неё через полное имя.


module MathStuff =

    let add x y  = x + y
    let subtract x y  = x - y

module OtherStuff =

    // используем функцию из модуля MathStuff
    let add1 x = MathStuff.add x 1  

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


module OtherStuff =
    open MathStuff  // делаем доступными все функции модуля

    let add1 x = add x 1

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


Вложенные модули


Как и статические классы, модули могут содержать вложенные модули:


module MathStuff =

    let add x y  = x + y
    let subtract x y  = x - y

    // вложенный модуль
    module FloatLib =

        let add x y :float = x + y
        let subtract x y :float  = x - y

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


module OtherStuff =
    open MathStuff

    let add1 x = add x 1

    // полное имя
    let add1Float x = MathStuff.FloatLib.add x 1.0

    // относительное имя
    let sub1Float x = FloatLib.subtract x 1.0

Модули верхнего уровня


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


Модули "верхнего уровня" определяются несколько иначе, в отличие от модулей, которые были показаны ранее.


  • Строка module MyModuleName должна быть первой декларацией в файле
  • Знак = отсутствует
  • Содержимое модуля не должно иметь отступа

В общем случае в каждом исходном .FS файле должна существовать декларация "верхнего уровня". Есть кое-какие исключения, но всё равно это хорошая практика. Имя модуля не обязано совпадать с именем файла, но два файла не могут содержать модули с одинаковыми именами.


Для .FSX файлов декларация модуля не нужна, в данном случае имя файла скрипта автоматически становится именем модуля.


Пример MathStuff, объявленного в качестве модуля "верхнего модуля":


// модуль верхнего уровня
module MathStuff

let add x y  = x + y
let subtract x y  = x - y

// вложенный модуль
module FloatLib =

    let add x y :float = x + y
    let subtract x y :float  = x - y

Обратите внимание на отсутствие отступа в коде "верхнего уровня" (содержимом module MathStuff), в то время как содержимое вложенного модуля FloatLib всё ещё обязано иметь отступ.


Другое содержимое модулей


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


module MathStuff =

    // функции
    let add x y  = x + y
    let subtract x y  = x - y

    // объявления типов
    type Complex = {r:float; i:float}
    type IntegerFunction = int -> int -> int
    type DegreesOrRadians = Deg | Rad

    // "константы"
    let PI = 3.141

    // "переменные"
    let mutable TrigType = Deg

    // инициализация / статический конструктор
    do printfn "module initialized"

Кстати, если вы запускаете данные примеры в интерактивном режиме, вам может понадобиться достаточно часто перезапускать сессию, чтобы код оставался "свежим" и не заражался предыдущими вычислениями.


Сокрытие (Перекрытие, Shadowing)


Это снова наш пример модуля. Обратите внимание, что MathStuff содержит функцию add также как и FloatLib.


module MathStuff =

    let add x y  = x + y
    let subtract x y  = x - y

    // вложенный модуль
    module FloatLib =

        let add x y :float = x + y
        let subtract x y :float  = x - y

Что произойдёт, если открыть оба модуля в текущей области видимости и вызвать add?


open  MathStuff
open  MathStuff.FloatLib

let result = add 1 2  // Compiler error: This expression was expected to
                      // have type float but here has type int

А произошло то, что модуль MathStuff.FloatLib переопределил оригинальный MathStuff, который был перекрыт (сокрыт, "shadowed") модулем FloatLib.


В результате получаем FS0001 compiler error, потому что первый параметр 1 ожидался как float. Чтобы это исправить, придётся изменить 1 на 1.0.


К сожалению, на практике это незаметно и легко упускается из виду. Иногда с помощью такого приёма можно делать интересные трюки, почти как подклассы, но чаще всего наличие одноимённых функций раздражает (например, в случае крайне распространенной функции map).


Если вы хотите избежать данного поведения, существует способ пресечь его при помощи атрибута RequireQualifiedAccess. Тот же пример, у которого оба модуля декорированы этим атрибутом:


[<RequireQualifiedAccess>]
module MathStuff =

    let add x y  = x + y
    let subtract x y  = x - y

    // вложенный модуль
    [<RequireQualifiedAccess>]
    module FloatLib =

        let add x y :float = x + y
        let subtract x y :float  = x - y

Теперь директива open недоступна:


open  MathStuff   // ошибка
open  MathStuff.FloatLib // ошибка

Но по прежнему можно получить доступ к функциям (без какой-либо двусмысленности) через их полные имена:


let result = MathStuff.add 1 2  
let result = MathStuff.FloatLib.add 1.0 2.0

Контроль доступа


F# поддерживает использование стандартных операторов контроля доступа из .NET, таких как public, private и internal. Статья MSDN содержит полную информацию.


  • Эти спецификаторы доступа могут быть применены к ("let bound") функциям верхнего уровня, значениям, типам и другим объявлениям в модуле. Они также могут быть указаны для самих модулей (например может понадобиться приватный вложенный модуль).
  • По умолчанию всё имеет публичный доступ (за исключением нескольких случаев), поэтому для их защиты потребуется использовать private или internal.

Данные спецификаторы доступа являются лишь одним из способов управления видимостью в F#. Совершенно другой способ — использование файлов "сигнатур", которые напоминают заголовочные файлы языка C. Они абстрактно описывают содержимое модуля. Сигнатуры очень полезны для серьёзной инкапсуляции, но для рассмотрения их возможностей придётся дождаться запланированной серии по инкапсуляции и безопасности на основе возможностей.


Пространства имён


Пространства имён в F# похожи на пространства имён из C#. Они могут быть использованы для организации модулей и типов, чтобы избежать конфликтов имён.


Пространство имён, объявленное с помощью ключевого слова namespace:


namespace Utilities

module MathStuff =

    // функции
    let add x y  = x + y
    let subtract x y  = x - y

Из-за этого пространства имён полное имя модуля MathStuff стало Utilities.MathStuff, а полное имя addUtilities.MathStuff.add.


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


Также можно объявлять пространство имён явно при помощи добавления точки в имени модуля. Т.е. код выше можно переписать так:


module Utilities.MathStuff  

// функции
let add x y  = x + y
let subtract x y  = x - y

Полное имя модуля MathStuff всё ещё Utilities.MathStuff, но теперь это модуль верхнего уровня и его содержимому не нужен отступ.


Некоторые дополнительные особенности использования пространств имён:


  • Пространства имён необязательны для модулей. В отличии от C#, для F# проектов не существует пространства имён по умолчанию, так что модуль верхнего уровня без пространства имён будет глобальным. Если планируется создание многократно используемых библиотек, необходимо добавить несколько пространств имён, чтобы избежать коллизий с кодом других библиотек.
  • Пространства имён могут непосредственно содержать объявления типов, но не объявления функций. Как было замечено ранее, все объявления функций и значений должны быть частью модуля.
  • Наконец, следует иметь ввиду, что пространства имён не работают в скриптах. Например, если попробовать отправить объявление пространства имён, такое как namespace Utilities, в интерактивное окно, будет получена ошибка.

Иерархия пространств имён


Можно выстраивать иерархию пространств имён, просто разделив имена точками:


namespace Core.Utilities

module MathStuff = 
    let add x y  = x + y

Так же вы можете объявить два пространства имён в одном файле, если хотите. Следует обратить внимание, что все пространства имён должны быть объявлены полным именем — они не поддерживают вложенность.


namespace Core.Utilities

module MathStuff =
    let add x y  = x + y

namespace Core.Extra

module MoreMathStuff =
    let add x y  = x + y

Конфликт имён между пространством имён и модулем невозможен.


namespace Core.Utilities

module MathStuff =
    let add x y  = x + y

namespace Core

// полное имя модуля - Core.Utilities  
// коллизия с пространством имён выше!
module Utilities =
    let add x y  = x + y

Смешивание типов и функций в модулях


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


В ООП структуры данных и функции над ними, были бы объединены вместе в рамках класса. А в функциональном F# структуры данных и функции над ними объединяются в модуль.


Существуют два паттерна комбинирования типов и функций вместе:


  • тип объявляется отдельно от функций
  • тип объявляется в том же модуле, что и функции

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


// модуль верхнего уровня
namespace Example

// объявляем тип за пределами модуля
type PersonType = {First:string; Last:string}

// объявляем модуль для функций, которые работают с типом
module Person =

    // конструктор
    let create first last =
        {First=first; Last=last}

    // метод, который работает с типом
    let fullName {First=first; Last=last} =
        first + " " + last

let person = Person.create "john" "doe" 
Person.fullName person |> printfn "Fullname=%s"

В альтернативном варианте тип декларируется внутри модуля и имеет простое название, типа "T" или имя модуля. Доступ к функциям осуществляется приблизительно так: MyModule.Func и MyModule.Func2, а доступ к типу: MyModule.T:


module Customer =

    // Customer.T - это основной тип для модуля
    type T = {AccountId:int; Name:string}

    // конструктор
    let create id name =
        {T.AccountId=id; T.Name=name}

    // метод, который работает с типом
    let isValid {T.AccountId=id; } =
        id > 0

let customer = Customer.create 42 "bob"
Customer.isValid customer |> printfn "Is valid?=%b"

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


Итак, какой способ выбрать?


  • Первый подход больше похож на классический .NET, и его следует предпочесть, если планируется использовать данную библиотеку для кода за пределами F#, где ожидается отдельно существующий класс.
  • Второй подход является более распространённым в других функциональных языках. Тип внутри модуля компилируется как вложенный класс, что как правило не очень удобно для ООП языков.

Для себя вы можете поэкспериментировать с обоими способами. В случае командной разработки надлежит выбрать один стиль.


Модули, содержащие только типы


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


Например, вы можете захотеть сделать так:


// модуль верхнего уровня
module Example

// объявляем тип внутри модуля
type PersonType = {First:string; Last:string}

// никаких функций в модуле, только типы...

А вот другой способ сделать тоже самое. Слово module просто заменяется на слово namespace.


// используем пространство имён
namespace Example

// объявляем тип вне модуля
type PersonType = {First:string; Last:string}

В обоих случаях PersonType будет иметь одно и тоже полное имя.


Обратите внимание, что данная замена работает только с типами. Функции всегда должны быть объявлены внутри модуля.


Дополнительные ресурсы


Для F# существует множество самоучителей, включая материалы для тех, кто пришел с опытом C# или Java. Следующие ссылки могут быть полезными по мере того, как вы будете глубже изучать F#:



Также описаны еще несколько способов, как начать изучение F#.


И наконец, сообщество F# очень дружелюбно к начинающим. Есть очень активный чат в Slack, поддерживаемый F# Software Foundation, с комнатами для начинающих, к которым вы можете свободно присоединиться. Мы настоятельно рекомендуем вам это сделать!


Не забудьте посетить сайт русскоязычного сообщества F#! Если у вас возникнут вопросы по изучению языка, мы будем рады обсудить их в чатах:



Об авторах перевода


Автор перевода @kleidemos
Перевод и редакторские правки сделаны усилиями русскоязычного сообщества F#-разработчиков. Мы также благодарим @schvepsss и @shwars за подготовку данной статьи к публикации.

Вы можете помочь и перевести немного средств на развитие сайта



Комментарии (1):

  1. Neftedollar
    /#19812746

    Огромное спасибо переводчику