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

в 7:00, , рубрики: .net, F#, fsharp, fsharplangru, microsoft, Блог компании Microsoft, математика, Программирование, функциональное программирование

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

Функциональное мышление. Часть 10 - 1


В F# это возможно с помощью фичи, которая называется "расширение типов" ("type extensions"). У любого F# типа, не только класса, могут быть прикреплённые функции.

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

module Person =
    type T = {First:string; Last:string} with
        // функция-член, объявленная вместе с типом
        member this.FullName =
            this.First + " " + this.Last

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

let person = Person.create "John" "Doe"
let fullname = person.FullName

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

  • Ключевое слово with обозначает начало списка членов
  • Ключевое слово member показывает, что функция является членом (т.е. методом)
  • Слово this является меткой объекта, на котором вызывается данный метод (также называемая "self-identifier"). Это слово является префиксом имени функции, и внутри функции можно использовать его для обращения к текущему экземпляру. Не существует требований к словам, используемым в качестве самоидентификатора, достаточно чтобы они были устойчивы. Можно использовать this, self, me или любое другое слово, которое обычно используется как отсылка на самого себя.

Нет нужды добавлять член вместе с объявлением типа, всегда можно добавить его позднее в том же модуле:

module Person =
    type T = {First:string; Last:string} with
       // член, объявленный вместе с типом
        member this.FullName =
            this.First + " " + this.Last

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

    // другой член, объявленный позже
    type T with
        member this.SortableName =
            this.Last + ", " + this.First

let person = Person.create "John" "Doe"
let fullname = person.FullName
let sortableName = person.SortableName

Эти примеры демонстрируют вызов "встроенных расширений" ("intrinsic extensions"). Они компилируются в тип и будут доступны везде, где бы тип ни использовался. Они также будут показаны при использовании рефлексии.

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

Опциональные расширения

Альтернативный вариант заключается в том, что можно добавить дополнительный член из совершенно другого модуля. Их называют "опциональными расширениями". Они не компилируются внутрь класса, и требуют другой модуль в области видимости для работы с ними (данное поведение напоминает методы-расширения из C#).

Например, пусть определен тип Person:

module Person =
    type T = {First:string; Last:string} with
        // член, объявленный вместе с типом
        member this.FullName =
            this.First + " " + this.Last

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

    // ещё один член, объявленный позже
    type T with
        member this.SortableName =
            this.Last + ", " + this.First

Пример ниже демонстрирует, как можно добавить расширение UppercaseName к нему в другом модуле:

// в другом модуле
module PersonExtensions =

    type Person.T with
    member this.UppercaseName =
        this.FullName.ToUpper()

Теперь можно попробовать это расширение:

let person = Person.create "John" "Doe"
let uppercaseName = person.UppercaseName

Упс, получаем ошибку. Она произошла потому, что PersonExtensions не находится в области видимости. Как и в C#, чтобы использовать любые расширения, их нужно ввести в область видимости.

Как только мы сделаем это, все заработает:

// Сначала сделаем расширение доступным!
open PersonExtensions

let person = Person.create "John" "Doe"
let uppercaseName = person.UppercaseName

Расширение системных типов

Можно также расширять типы из .NET библиотек. Но следует иметь ввиду, что при расширении типа надо использовать его фактическое имя, а не псевдоним.

Например, если попробовать расширить int, ничего не получится, т.к. int не является правильным именем для типа:

type int with
    member this.IsEven = this % 2 = 0

Вместо этого нужно использовать System.Int32:

type System.Int32 with
    member this.IsEven = this % 2 = 0

let i = 20
if i.IsEven then printfn "'%i' is even" i

Статические члены

Можно создавать статические функции-члены с помощью:

  • добавления ключевого слова static
  • удаления метки this

module Person =
    type T = {First:string; Last:string} with
        // член, определённый вместе с типом
        member this.FullName =
            this.First + " " + this.Last

        // статический конструктор
        static member Create first last =
            {First=first; Last=last}

let person = Person.T.Create "John" "Doe"
let fullname = person.FullName

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

type System.Int32 with
    static member IsOdd x = x % 2 = 1

type System.Double with
    static member Pi = 3.141

let result = System.Int32.IsOdd 20
let pi = System.Double.Pi

Прикрепление существующих функций

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

  • Во время разработки можно объявлять самостоятельные функции, которые ссылаются на другие самостоятельные функции. Это упростит разработку, поскольку вывод типов гораздо лучше работает с функциональным стилем, нежели с объектно-ориентированным ("через точку").
  • Но некоторые ключевые функции можно прикрепить к типу. Это позволяет пользователям выбирать, какой из стилей использовать — функциональный или объектно-ориентированный.

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

let list = [1..10]

// функциональный стиль
let len1 = List.length list

// объектно-ориентированный стиль
let len2 = list.Length

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

module Person =
    // тип, изначально не имеющий членов
    type T = {First:string; Last:string}

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

    // самостоятельная функция
    let fullName {First=first; Last=last} =
        first + " " + last

    // присоединение существующей функции в качестве члена
    type T with
        member this.FullName = fullName this

let person = Person.create "John" "Doe"
let fullname = Person.fullName person  // ФП
let fullname2 = person.FullName        // ООП

Самостоятельная функция fullName имеет один параметр, person. Присоединённый же член получает параметр из self-ссылки.

Добавление существующих функций с несколькими параметрами

Есть ещё одна приятная особенность. Если определённая ранее функция принимает несколько параметров, то когда вы будете прикреплять её к типу, вам не придётся перечислять все эти параметры снова. Достаточно указать параметр this первым.

В примере ниже функция hasSameFirstAndLastName имеет три параметра. Однако при прикреплении достаточно упомянуть всего лишь один!

module Person =
    // Тип без членов
    type T = {First:string; Last:string}

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

    // самостоятельная функция
    let hasSameFirstAndLastName (person:T) otherFirst otherLast =
        person.First = otherFirst && person.Last = otherLast

    // присоединение функции в качестве члена
    type T with
        member this.HasSameFirstAndLastName = hasSameFirstAndLastName this

let person = Person.create "John" "Doe"
let result1 = Person.hasSameFirstAndLastName person "bob" "smith" // ФП
let result2 = person.HasSameFirstAndLastName "bob" "smith" // ООП

Почему это работает? Подсказка: подумайте о каррировании и частичном применении!

Кортежные методы

Когда у нас появляются методы с более чем одним параметром, необходимо принять решение:

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

Каррированая форма более функциональная, в то время как кортежная форма более объектно-ориентированная.

Кортежная форма также используется для взаимодействия F# со стандартными библиотеками .NET, поэтому стоит рассмотреть данный подход более детально.

Нашим испытательным полигоном будет тип Product с двумя методами, каждый из которых реализован одним из способов, описанных выше. Методы CurriedTotal и TupleTotal делают одно и то же: вычисляют итоговую стоимость товара по заданным количеству и скидке.

type Product = {SKU:string; Price: float} with

    // каррированная форма
    member this.CurriedTotal qty discount =
        (this.Price * float qty) - discount

    // кортежная форма
    member this.TupleTotal(qty,discount) =
        (this.Price * float qty) - discount

Тестовый код:

let product = {SKU="ABC"; Price=2.0}
let total1 = product.CurriedTotal 10 1.0
let total2 = product.TupleTotal(10,1.0)

Пока нет особой разницы.

Но мы знаем, что каррированная версия может быть частично применена:

let totalFor10 = product.CurriedTotal 10
let discounts = [1.0..5.0]
let totalForDifferentDiscounts
    = discounts |> List.map totalFor10

С другой стороны, кортежная версия способна на то, что не может каррированая, а именно:

  • Именованные параметры
  • Необязательные параметры
  • Перегрузки

Именованные параметры с параметрами в форме кортежа

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

let product = {SKU="ABC"; Price=2.0}
let total3 = product.TupleTotal(qty=10,discount=1.0)
let total4 = product.TupleTotal(discount=1.0, qty=10)

Как видите, это позволяет менять порядок аргументов с помощью явного указания имен.

Внимание: если лишь у некоторой части параметров есть имена, то эти параметры всегда должна находиться в конце.

Необязательные параметры с параметрами в форме кортежа

Для методов с параметрами в форме кортежа можно помечать параметры как опциональные при помощи префикса в виде знака вопроса перед именем параметра.

  • Если параметр задан, то в функцию будет передано Some value
  • Иначе придет None

Пример:

type Product = {SKU:string; Price: float} with

    // Опциональная скидка
    member this.TupleTotal2(qty,?discount) =
        let extPrice = this.Price * float qty
        match discount with
        | None -> extPrice
        | Some discount -> extPrice - discount

И тест:

let product = {SKU="ABC"; Price=2.0}

// скидка не передана
let total1 = product.TupleTotal2(10)

// скидка передана
let total2 = product.TupleTotal2(10,1.0)

Явная проверка на None и Some может быть утомительной, но для обработки опциональных параметров существует более элегантное решение.

Существует функция defaultArg, которая принимает имя параметра в качестве первого аргумента и значение по умолчанию в качестве второго. Если параметр установлен, будет возвращено соответствующее значение, иначе — значение по умолчанию.

Тот же код с применением defaulArg:

type Product = {SKU:string; Price: float} with

    // опциональная скидка
    member this.TupleTotal2(qty,?discount) = 
        let extPrice = this.Price * float qty
        let discount = defaultArg discount 0.0
        extPrice - discount

Перегрузка методов

В C# можно создать несколько методов с одинаковым именем, которые отличаются своей сигнатурой (например, различные типы параметров и/или их количество).

В чисто функциональной модели это не имеет смысла — функция работает с конкретным типом аргумента (domain) и конкретным типом возвращаемого значения (range). Одна и та же функция не может взаимодействовать с другими domain и range.

Однако, F# поддерживает перегрузку методов, но только для методов (которые прикреплены к типам) и только тех из них, которые написаны в кортежном стиле.

Вот пример с еще одним вариантом метода TupleTotal!

type Product = {SKU:string; Price: float} with

    // без скидки
    member this.TupleTotal3(qty) =
        printfn "using non-discount method"
        this.Price * float qty

    // со скидкой
    member this.TupleTotal3(qty, discount) =
        printfn "using discount method"
        (this.Price * float qty) - discount

Как правило компилятор F# ругается на то, что существует два метода с одинаковым именем, но в данном случае это приемлемо, т.к. они объявлены в кортежной нотации и их сигнатуры различаются. (Чтобы было понятно, какой из методов вызывается, я добавил небольшие сообщения для отладки)

Пример использования:

let product = {SKU="ABC"; Price=2.0}

// скидка не указана
let total1 = product.TupleTotal3(10)

// скидка указана
let total2 = product.TupleTotal3(10,1.0)

Эй! Не так быстро… Недостатки использования методов

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

  • Методы плохо работают с выводом типов
  • Методы плохо работают с функциями высшего порядка

На самом деле, злоупотребляя методами, можно упустить самые сильные и полезные стороны программирования на F#.

Посмотрим, что я имею ввиду.

Методы плохо взаимодействуют с выводом типов

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

module Person =
    // тип без методов
    type T = {First:string; Last:string}

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

    // самостоятельная функция
    let fullName {First=first; Last=last} =
        first + " " + last

    // функция-член
    type T with
        member this.FullName = fullName this

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

Код, использующий самостоятельную функцию из модуля:

open Person

// использование самостоятельной функции
let printFullName person =
    printfn "Name is %s" (fullName person)

// Сработал вывод типов
//    val printFullName : Person.T -> unit

Компилируется без проблем, а вывод типов корректно идентифицирует параметр как Person.

Теперь попробуем версию через точку:

open Person

// обращение к методу "через точку"
let printFullName2 person =
    printfn "Name is %s" (person.FullName)

Этот код вообще не скомпилируется, т.к. вывод типов не имеет достаточной информации, чтобы определить тип параметра. Любой объект может реализовывать .FullName — этого недостаточно для вывода.

Да, мы можем аннотировать функцию типом параметра, но из-за этого теряется весь смысл автоматического вывода типов.

Методы плохо сочетаются с функциями высшего порядка

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

В случае самостоятельной функции решение тривиально:

open Person

let list = [
    Person.create "Andy" "Anderson";
    Person.create "John" "Johnson";
    Person.create "Jack" "Jackson"]

// получение всех полных имён
list |> List.map fullName

В случае метода объекта, придется везде создавать специальную лямбду:

open Person

let list = [
    Person.create "Andy" "Anderson";
    Person.create "John" "Johnson";
    Person.create "Jack" "Jackson"]

// получение всех имён
list |> List.map (fun p -> p.FullName)

А ведь это еще достаточно простой пример. Методы объектов довольно поддаются композиции, неудобны в конвейере и т.д.

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

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

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

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

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

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

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

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

Автор: Дмитрий Сошников

Источник

* - обязательные к заполнению поля