Pull to refresh

Библиотечные паттерны: Почему фреймворки — это зло

Reading time 14 min
Views 19K
Original author: Tomas Petricek
Здравствуйте, уважаемые читатели!

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


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

В предыдущей статье я описал некоторые принципы, которые мне пригодились при написании библиотек в функциональном стиле. Я опираюсь на собственный опыт создания библиотек на языке F#, но идеи, которые будут здесь изложены, достаточно универсальные и будут полезны при работе с любым языком программирования. В предыдущем посте я писал, как множественные уровни абстрагирования позволяют создавать библиотеки, упрощающие реализацию 80% сценариев, но при этом полезные и в более интересных практических случаях.

В этом материале я собираюсь поговорить о двух других аспектах: как разрабатывать компонуемые библиотеки и как (а главное — зачем) избегать обратных вызовов при разработке библиотек. Как понятно из названия статьи, суть ее сводится к следующему: пишите библиотеки, а не фреймворки!
Сравнение фреймворков и библиотек

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

  • Фреймворки. Когда вы пользуетесь фреймворком, его задача — обеспечивать работу системы. Он определяет ряд точек расширения (интерфейсов), где вы подключаете ваши реализации.
  • Библиотеки. При использовании библиотеки за работу системы отвечаете вы. Библиотека определяет ряд точек, через которые вы можете получить к ней доступ (функции и типы), и ваш код вызывает их по мере необходимости.




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

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

Что плохого во фреймворках?

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

Фреймворки не компонуются


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

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



Теоретическое отступление

Я не утверждаю, что следующие соображения обладают какой-либо теоретической базой, но фреймворки немного напоминают монады. Если вы находитесь вне монады, то «попасть внутрь» нее можно при помощи модуля. Затем можно выполнять внутри монады различные операции, но выйти из нее уже не можете. Фреймворки подобны таким монадам.
Хорошо известно, что компоновать монады сложно (как и фреймворки). Если у вас есть монады M1 и M2, то их можно состыковать при помощи операции M1(M2 α)→M2(M1 α), т.e. менять местами объемлемую и объемлющую монады. Можно ли что-либо подобное сделать с фреймворками?


Фреймворки сложно исследовать

Другая серьезная проблема с фреймворками заключается в том, что их сложно тестировать и исследовать. В F# очень полезно загрузить библиотеку в среду F# Interactiveб попробовать прогнать ее с различными вариантами ввода и посмотреть, что делает библиотека. Например, можно воспользоваться библиотекой для веб-разработки Suave, чтобы запустить простой веб-сервер, вот так:

 // Ссылаемся на библиотеку и открываем пространства имен
#r "Suave.0.25.0/lib/net40/Suave.dll"
open Suave.Web 
open Suave.Http
 
// Запускаем просто веб-сервер, выводящий на экран слово hello
startWebServer defaultConfig <| fun ctx -> async {
  let whoOpt = ctx.request.queryParam "who"
  let message = sprintf "Hello %s" (defaultArg whoOpt "world")
  return! ctx |> Successful.OK message }


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

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

Теоретическое отступление

Разница между библиотеками и фреймворками во многом подобна той, что существует между вызовом функции и необходимостью указания функции в качестве аргумента:
lib:τ1→τ2(library)
fwk:(σ2→σ1)→unit(framework)
В случае с library вам потребуется создать значение τ1, так, чтобы можно было вызвать функцию lib. Иногда библиотека предоставляет вам другие функции, создающие τ1 (в таком случае, нужно просто найти первую функцию из такой цепочки и вызвать ее). При интерактивном написании кода можно попробовать задавать различные значения τ1, запускать функцию и смотреть, что она возвращает. Таким образом можно без труда исследовать поведение библиотеки (и как с ее помощью добиваться того, что вам нужно). Кроме того, в таком случае упрощается тестирование кода, использующего библиотеки.

В случае с фреймворком складывается более сложная ситуация. Приходится писать функцию, которая принимает σ2 и выдает σ1. Первая проблема заключается в том, что вы не вполне представляете, какое значение σ2 будете получать в различных ситуациях. В идеальном мире «недопустимые значения непредставимы», но в реальности вы хотите начать писать такой код, который, в первую очередь, обрабатывает наиболее распространенные случаи. Не менее сложно понять (и исследовать), какие значения σ1 вы должны выдавать, чтобы добиться желаемого поведения.


Теперь, если вернуться к моему примеру с Suave, у читателя может возникнуть вопрос: это библиотека (мы вызываем функцию) или фреймворк (указываем функцию, которая должна быть вызвана). На самом деле, в вышеприведенном примере демонстрируются оба аспекта. Как будет показано далее, такой вариант «фреймворковой» структуры не так и плох (см. разделы об обратных вызовах и async ниже).

Фреймворки определяют организацию вашего кода

Следующая проблема с фреймворками заключается в том, что они определяют структуру вашего кода. Типичный пример подобного случая: мы работаем с фреймворком, требующим наследовать от определенного базового класса и реализовать конкретные методы. Например, класс Game во фреймворке XNA выглядит примерно так (я знаю, что XNA мертв, но такой паттерн применяется и в других схожих фреймворках):

class Game {
  abstract void Initialize();
  abstract void Draw(DrawingContext ctx);
  abstract void Update();
}


Предполагается, что в Initialize вы будете загружать любые ресурсы, которые могут потребоваться в вашей игре; Update многократно вызывается для вычисления следующего состояния, а Draw вызывается, когда требуется обновить экран. Интерфейс выраженно ориентирован на императивную модель программирования, поэтому у вас получится примерно такой фрагмент кода, который показан ниже. Здесь мы пишем глупый вариант игры в «Марио», где Марио просто медленно идет слева направо:

type MyGame() =
inherit Xna.Game()
let mutable x = 0
let mutable mario = None

override this.Initialize() = 
mario <- Some(Image.Load("mario.png"))
override this.Update() =
x <- x + 1
override this.Draw(ctx) =
mario |> Option.iter (fun mario ->
ctx.Draw(x, 0, mario))


Структура фреймворка как таковая не способствует написанию в нем красивого кода. Здесь я просто сделал максимально прямолинейную реализацию. Изменяемое поле x соответствует расположению Марио, а mario — это значение option для хранения ресурса.

Вы могли бы возразить, что в C# подобный код мог получиться более красивым (например, мне пришлось использовать значение option, так как все поля F# должны быть инициализированы), но это верно лишь в том случае, если полностью проигнорировать проверку. Фактически, используя здесь значение option, мы делаем код более безопасным (так как не можем случайно применить mario в Draw, если не инициализировали его). Или же фреймворк гарантирует, что Initialize всегда будет вызываться до Draw? Откуда нам это знать?

Как избежать «запахов» фреймворков

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

Поддерживайте интерактивное исследование

Даже если вы не пишете библиотеку на F#, следует использовать F# Interactive, чтобы можно было использовать ее интерактивно! Дело не только в том, что язык F# отлично приспособлен для документирования библиотеки, но и в том, что, написав интерактивный сценарий, можно быть уверенным, что вызывать вашу библиотеку будет очень легко (если вы работаете на платформе .NET, то есть и другой вариант — работать с LINQPad).

Проиллюстрирую мои рассуждения на двух примерах. В первом фрагменте кода показано, как можно использовать библиотеку F# Formatting library, чтобы преобразовать в HTML-файл каталог с документацией, содержащий файлы сценариев F# и документы Markdown, либо как обработать отдельный файл:

#r "FSharp.Literate.dll"
open FSharp.Literate

// обрабатываем целый каталог
Literate.ProcessDirectory("C:/demo/docs")

// обрабатываем два отдельных документа
Literate.ProcessMarkdown("C:/demo/docs/sample.md")
Literate.ProcessScriptFile("C:/demo/docs/sample.fsx")


Смысл в том, что вам требуется сослаться на библиотеку, открыть пространство имен и найти тип Literate в качестве входной точки. Сделав это, можно воспользоваться "." и посмотреть, что у вас есть!

Думаю, все хорошие библиотеки должны поддерживать подобную практику. В качестве другого примера давайте рассмотрим FunScript, преобразующий код F# в JavaScript. Как правило, он используется в составе какого-либо веб-фреймворка, однако отлично работает и сам по себе. Следующий фрагмент генерирует код JavaScript для простого цикла async, который каждую секунду увеличивает на единицу номер на странице :

#r "FunScript.dll"
#r "FunScript.TypeScript.Binding.lib.dll"
open FunScript
open FunScript.TypeScript

Compiler.compile 
  <@ let rec loop n : Async<unit> = async {
        Globals.window.document.title <- string n       
        do! Async.Sleep(1000)
        return! loop (n + 1) }
      loop 0 @>


Опять же, мы просто ссылаемся на библиотеку (в данном случае — и на DOM-привязки), а затем вызываем одну функцию — функция compile принимает цитату F#. Обнаружив это, можно самому опробовать, какие вещи она может обрабатывать! В предыдущем примере показана красивая поддержка F# async {… } и привязок, открывающих вам доступ к DOM.

Используйте только простые обратные вызовы

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

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

// Стандартные функции для обработки списков
[ 1 .. 10 ]
|> List.filter (fun n -> n%3 = 0)
|> List.map (fun n -> n*10)

// Вызывает первую функцию для считывания ввода, валидирует его, 
// а затем вызывает вторую функцию для обработки этой информации
readAndProcess
  (fun () -> File.ReadAllText("C:/demo.txt"))
  (fun s -> s.ToUpper())


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

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

Во-вторых, readAndProcess обязывает нас вернуть состояние string от первой функции, а затем принять string в качестве ввода для второй функции. Это иная потенциальная проблема. Что если нам придется передать какое-либо другое состояние от первой функции ко второй?

Разумеется, здесь я рассматриваю упрощенный случай, но давайте посмотрим, что может происходить внутри readAndProcess. Эта функция может обрабатывать некоторые исключения и сначала проверять валидность ввода, а лишь затем вызывать второй аргумент:

let readAndProcess readInput processInput =
  try
    let input = readInput()
    if input = null || input = "" then None
    else Some(processInput input)
  with :? System.IO.IOException -> None


Как можно было бы улучшить эту абстракцию? Прежде всего, данная функция, на самом деле решает две задачи. Во-первых, она обрабатывает исключения (довольно тупо, но это ведь учебный пример). Во-вторых, она валидирует ввод. Мы можем разделить ее на две функции:

let ignoreIOErrors f =
  try Some (f())
  with :? System.IO.IOException -> None
let validateInput input = 
  if input = null || input = "" then None else Some(input)


Теперь validateInput превращается в самую обычную функцию, которая возвращает Some, если ввод был валиден. Функция ignoreIOErrors по-прежнему принимает функцию в качестве аргумента – в данном случае это целесообразно, поскольку обработка исключений есть типичный пример паттерна Hole in the Middle. При помощи новых функций можно написать:

ignoreIOErrors (fun () ->
  let input = File.ReadAllText("C:/demo.txt")
  validateInput input 
  |> Option.map (fun valid -> valid.ToUpper() ))


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

По-моему, это плюс, так как вы видите, что происходит (и можете начать с интерактивного вызова validateInput!) Кроме того, если вам больше нравится функция readAndProcess, это тоже хорошо — вы можете с легкостью определить ее при помощи двух вышеприведенных функций (но не наоборот!) Итак, ваша библиотека может обеспечить многоуровневую абстракцию, о чем шла речь в моей предыдущей статье. Но если мы предоставим только высокоуровневую абстракцию, это ограничит наши возможности.

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

Инвертирование обратных вызовов при помощи событий и async

Говоря о том, как фреймворки влияют на организацию вашего кода, я привел в качестве примера простой игровой движок. Что можно было бы сделать иначе, чтобы не приходилось использовать изменяемые поля и реализовывать конкретный класс? В F# можно было бы воспользоваться асинхронными рабочими потоками и событийно-ориентированной моделью программирования.

Ситуация осложняется в тех языках, где нет ничего похожего на вычислительные выражения (равно как и итераторов, позволяющих сымитировать такой функционал), однако C# поддерживает await, в F# есть вычислительные выражения, в Haskell — нотация do, а в Python, пожалуй, можно злоупотребить генераторами.

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

type Game = 
  member Update : IEvent<unit>
  member Draw : IEvent<DrawingContext>
  member IsRunning : bool


При работе с F# async мы можем написать код иначе. Возвращаясь к исходной посылке сравнения фреймворков и библиотек, мы можем добиться полного контроля над всем происходящим! В следующем примере сначала инициализируются ресурсы и объект Game, а затем реализуется цикл (при помощи рекурсивных блоков async), ожидающий события Update или Draw при помощи метода AwaitObservable:

// Инициализируем игру и ресурсы
let game = Inverted.Game()
let mario = Image.Load("mario.png")

// Рекурсивный цикл, работающий до самого конца игры 
let rec loop x = async {
  if game.IsRunning then
    let! evt = Async.AwaitObservable(game.Update, game.Draw) 
    match evt with 
    | Choice1Of2() -> 
        // Обработка события 'Update' 
        return! loop (x + 1)
    | Choice2Of2(ctx) -> 
        // Обработка события 'Draw' 
        ctx.Draw(x, 0, mario) 
        return! loop x }

// Запуск Game с x=0
loop 0  


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

Ключевой момент здесь связан с использованием async {… }. Мы можем воспользоваться AwaitObservable, чтобы приказать: «возобнови вычисления, когда потребуется Update или Draw». Когда происходит событие, мы выполняем необходимое действие (обновляем состояние в строке 12 или отрисовываем Марио в строке 15), а затем продолжаем. Самое приятно в данном случае — то, что такой код легко можно расширить для получения более сложной логики — см. например статью Фила Трелфорда. Другой вариант реализации подобных свойств — использование агентов F#, что предоставляет вам схожий контроль над логикой.

Итак, теперь мы все контролируем, но многого ли мы этим добились? Если вы не привыкли к F#, то, скорее всего, вышеприведенный код покажется вам запутанным. Главная идея заключается в том, что, обращая управление, мы можем с легкостью писать собственные абстракции. Здесь мы подходим к завершающему этапу…

Используйте несколько уровней абстрагирования

Как я писал в предыдущем посте, библиотека должна обеспечивать несколько уровней абстрагирования. Тип Game, который я использовал в предыдущем фрагменте — это низкоуровневая абстракция; она полезна, если вы хотите построить что-нибудь затейливое, при этом обеспечивает вам полный контроль. Но в других случаях игра действительно может состоять из пары функций: «обновить» и «отрисовать».
Это делается без труда, так как мы можем просто взять предыдущий фрагмент кода и извлечь несколько частей в аргументы:

let startGame draw update init =
  let game = Inverted.Game()
  let rec loop x = async {
    if game.IsRunning then
      let! evt = Async.AwaitObservable(game.Update, game.Draw) 
      match evt with 
      | Choice1Of2() -> return! loop (update x)
      | Choice2Of2(ctx) -> 
          draw x ctx
          return! loop x }
  loop init


Абстракция startGame принимает в качестве аргументов две функции плюс исходное состояние. Функция update обновляет состояние, а функция draw отрисовывает его с использованием указанного контекста DrawingContext. Таким образом, мы можем записать нашу игру в «Марио» всего четырьмя строками:

let mario = Image.Load("mario.png")
0 |> startGame 
  (fun x ctx -> ctx.Draw(x, 0, mario))
  (fun x -> x + 1)


Если вы внимательно читали весь пост, то можете спросить: а не противоречу ли я здесь сам себе? Разве я не писал выше, что функции высшего порядка, принимающие множественные функции (особенно если они разделяют состояние) суть порочные фреймворки? Да, я это говорил! Но позвольте пояснить этот момент:

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

Можете взять четыре вышеприведенные строки, посмотреть на определение startGame и преобразовать их в 14 строк кода, которые мы уже видели выше (исключая комментарии). То есть, вы должны иметь возможность получить контроль, поместив один (не очень глубокий) этап под капот. Такая практика отличается от выстраивания хрупкого скаффолдинга поверх плохо сработанной библиотеки, к чему иногда приходится прибегать для написания красивого кода.

Разрабатывайте компонуемые библиотеки

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

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

Хороший пример такого рода — FsLab, пакет, объединяющий ряд пакетов F# для работы с данными (включая Deedle, Math.NET Numerics и др.). Пакет FsLab поставляется с одним сценарием, компонующим вместе ряд других библиотек (исходный код находится здесь).

Два простых примера из файла – это функции, выполняющие преобразование из матрицы в кадр (Matrix.toFrame) и в обратном направлении (Frame.toMatrix):

module Matrix =
  let inline toFrame matrix = 
    matrix |> Matrix.toArray2 |> Frame.ofArray2D

module Frame =
  let inline toMatrix frame = 
    frame |> Frame.toArray2D |> DenseMatrix.ofArray2


Решение здесь довольно простое, так как и кадр Deedle, и матрицы Math.NET могут быть преобразованы в двухмерный массив и обратно, поэтому мы должны просто переходить в массиве от одного элемента к другому.

Выглядит очень просто, но суть я усматриваю в следующем: независимо от того, что делает ваша библиотека, вы должны приложить максимум усилий, чтобы эту библиотеку с другими (либо заменять в ней те или иные компоненты, если они не нравятся!).
Tags:
Hubs:
+13
Comments 4
Comments Comments 4

Articles

Information

Website
piter.com
Registered
Founded
Employees
201–500 employees
Location
Россия