C# Zen Coding уже на F#
В моем предыдущем посте, где я описал идею CSharpZen, я пообещал две вещи – дописать расширение для Visual Studio 2010, а также записать вебкаст того, как это можно использовать. Но пока я дописывал код для трансформатора, мне снова показалось что код может стать более понятным если его переписать на F#. И понеслось…
Почему опять F#? Я уже писал о том что строковые трансформации лучше делать на F# и что они хоть и работают медленнее (хотя Zen Coding вообще написан на питоне и использует DLR), но код легче понимать. А у меня еще появляется работа с массивами неопределенной длины (например при обработке свойств может быть p:sb x а может p:new sb x) что весьма неплохо ложится на pattern matching.
В этом посте я хочу пройтись по проблемам прошлого поста и показать как в очередной раз F# позволяет нам производить аналогичный код но в более понятной форме.
Проблема использования метаданных
Помните как мы определили метаданные для видимости как аттрибуты? Так вот, этот подход хоть и казался полезным (держим метаданные рядом с полями), но на самом деле был жутко неудобным. В F# такой подход не сработает. Вместо этого, мы определяем перечисление отдельно…
type Visibility = | Public | Internal | ProtectedInternal | Protected | Private
…а метаданные определяем как массив кортежей которые содержат все нужные нам данные.
type Constants =
static member visibilityTokens =
[|(Visibility.Public, "+", "public");
(Visibility.Internal, @"\", "internal");
(Visibility.ProtectedInternal, @"/\", "protected internal");
(Visibility.Protected, @"/", "protected");
(Visibility.Private, "-", "private")|]
...
Теперь для поиска конретных элементов метаданных мы просто используем Array.find по кортежам. Например, та же функция вытаскивания Visibility из начала строки теперь выглядит вот так:
static member getVisibilityToken (entry:String) =
try
let idx = Constants.visibilityTokens |> Array.findIndex(fun (a,b,c) -> entry.StartsWith(b))
let (a, b, c) = Constants.visibilityTokens.[idx]
(a, entry.Replace(b, String.Empty))
with
:? KeyNotFoundException ->
let (a, b, c) = Constants.visibilityTokens.[0]
(a, entry)
Свойства расширения
F# знаменит тем что можно делать не только extension methods но и extension properties. Это хорошо. Единственная проблема это то, что методы расширения приходится класть в отдельный модуль, а я это не люблю – предпочитаю держать все в одном файле и типе, безо всяких там модулей. А тут не получилось – пришлось описывать все отдельно:
module Extensions =
type String with
member x.Cap =
let parts = x.Split([|'_'|], StringSplitOptions.RemoveEmptyEntries)
parts |> Array.map(fun f -> Char.ToUpper(f.[0]).ToString() +
(if f.Length > 1 then f.Substring(1) else String.Empty))
|> String.Concat
Этот код вообще эклектичен – взять хотя бы «заинлайненый» блок if-then-else прямо внутри map-а. Это что-то. И это что-то не очень-то читабельно. Хотя кому как.
Сборщик кода
Помните CodeBuilder? Так вот, решил я его на F# переписать. В чем-то он лучше, но вот навязчивое желание F# сохранять результаты вычислений или их игнорировать весьма раздражает. Код ниже дает качественно новое определение фразе «послать в игнор»:
type CodeBuilder(spaces) =
let mutable indent = 0
let sb = StringBuilder()
member x.IndentText =
String.Empty.PadRight(spaces * indent)
member x.Indent() =
indent <- indent + 1
x
member x.Unindent() =
indent <- Math.Max(0, indent - 1)
x
member x.Append(text:String) =
sb.Append(x.IndentText).Append(text) |> ignore
x
member x.AppendLine(text:String) =
sb.Append(x.IndentText).AppendLine(text) |> ignore
x
override x.ToString() =
sb.ToString()
Неадекват с мутабельностью и null
Кажется я уже об этом как-то писал. Суть в том, что F# лютой ненавистью ненавидит мутабельность и null. То есть он их поддерживает, но чтобы их использовать… короче Жигули тоже поддерживают езду по дороге, скажем так. Но то, что я вам сейчас покажу это вообще лебединая песня.
Для начала напомню что для того чтобы значение можно было изменять, его нужно пометить как mutable и для записи использовать оператор <-:
let mutable x = 0 x <- 2
Все бы хорошо, но это нелегитивно внутри паттерн-мэтчинга:
match myDrug with | Vicodin -> x <- 2 // Вам нельзя.
Суть в том, что при таком раскладе вы больше не можете пользоваться «обычным» подходом, вам нужно пересесть на синтаксис который использует ‘ref cells’. Я даже это переводить не буду, почитать можно тут. Если коротко, то используется совсем другой синтаксис:
let x = ref 0 x := !x + 1
А теперь представьте что вам нужно делать то же самое но для nullable-полей. Помните мои меременные context и rootContext? Вот-вот. Они nullable, и как вы помните, F# не поддерживает эту парадигму (точнее поддерживает, но через одно место) в результате чего мне не написать подобное:
let context:IVisitable = null // fail let context:IVisitable = ref null // epic fail
На самом деле, F# имеет свою парадигму option<'t> для подобных вещей. Только она тоже сработает только после дозы шаманства:
let context : IVisitable option = ref none // FAIL let context : Ref<IVisitable option> = ref None // OK, finally *phew*
Имхо, это просто умопомрачительно. Вы можете себе представить индийскую аутсорсинговую компанию, в которой программисты будут разбираться с подобным кодом? Я – нет.
Мутабельность переменных
И еще про мутабельность. В свое время я написал статейку про то как я использую F# DSL для оценки проектов. Так вот, там фигурирует такая неопрятная вещь как публичное поле-список:
[<DefaultValue>] val mutable Groups : Group list
Для отказа от List<T> реальных причин нет – зато есть желание делать все чуть более «функционально». За это мы платим тем, что при добавлении элемента, список полностью заменяется. Не знаю стоимость этой операции, возможно не так дорого, но выглядит странно.
x.Groups <- newGroup :: x.Groups
Зато теперь я могу показать как выглядит определение пространства имен:
type Namespace() = [<DefaultValue>] val mutable Name : String [<DefaultValue>] val mutable Classes : Class list ...
Вот вам и ужасный синтаксис во всей «красе». Зато все работает. Главные плюсы вообщем-то не здесь, а в паттерн-матчинге самого парсера.
Паттерн матчинг рулит
Вот собственно где все самое интересное. То, как организован паттер-матчинг в F# позволяет нам лаконично (ненавижу это слово!) описывать разные условия. Вот небольшой пример:
// split the new classifier and match against it let parts = x.splitToList(newClassifier, ':') match parts with | (* NAMESPACE *) "ns" :: tail -> let ns = Namespace() match tail with | name :: _ -> ns.Name <- name | _ -> ns.Name <- co.DefaultNamespace let nsv = ns :> IVisitable context := Some nsv setRootContext nsv
Получив классификатор, мы можем проверить, есть у него имя или нет. При этом избыточные элементы мэтчинга отбрасываются. Но если нам вдруг понадобится иметь «сложные» классификаторы вроде a:b:c то добавить их поддержку проще простого – это всего лишь еще один кейс:
| name :: somethingElse :: -> // что-то умное
Зато гибкость теряется в весьма неудобной (излишней) конверсии Namespace в Visitable. О да, кстати, давайте я быстренько покажу как работают интерфейсы в F#.
Интерфейсы
У нас всего один интерфейс – IVisitable. Но в F# ключевое слово interface используется только когда вы реализуете интерфейс в отдельном классе. Если вы просто хотите объявить интерфейс, то делаете класс с абстрактными объявлениями методов:
type IVisitable = abstract Visit : CodeBuilder * ConversionOptions -> unit
Реализация интерфейса всегда явная, т.е. вы прописываете у какого интерфейса и что вы реализуете. Вот пример полного определения класса Namespace:
type Namespace() =
[<DefaultValue>] val mutable Name : String
[<DefaultValue>] val mutable Classes : Class list
interface IVisitable with
member x.Visit(cb, co) =
cb.Append("namespace ").Append(x.Name.Cap).AppendLine(" {").Indent() |> ignore
List.iter(fun c -> (c :> IVisitable).Visit(cb, co)) |> ignore
cb.Unindent().AppendLine("}") |> ignore
Многоуровневый паттерн-мэтчинг
В парсере ни много ни мало 5 (пять!!!) уровней паттерн-мэтчинга. Вот четыре их них которые задействованы в разборе определения класса:
| (* CLASS *) "c" :: tail ->
// get the name
match tail with
| [] -> raise(Exception("Class is missing a name"))
| name :: _ ->
let c = Class(name, visibility)
let cv = c :> IVisitable
context := Some cv
match !rootContext with
| Some(x) ->
match x with
| :? Namespace as ns -> ns.Classes <- c :: ns.Classes
| _ -> ()
| None -> ()
setRootContext cv
Поскольку класс обязательно нужно положить внутрь простанства имен (если таковое имеется), нам приходится делать двойное сравнение с Option<Namespace> – сначала нужно проверить что это действительно Some(x), а потом проверить что тип совпадает (мало ли…). Проблема в том, что само по себе Ref<Namespace option> не ведет себя полиморфно и одним оператором :? в мэтче не обойтись. А жаль. Было бы удобно.
Реализация свойств
Ну и последний вынос мозга на сегодня. Помните я говорил что в свойство можно вставлять слово new? Интересно, но pattern matching возвращает значения. В контексте свойства мы можем взять свойство args (хвост наших токенов) и вернуть из него кортеж!
let (name, mustInit, skipCount) = match args with
| name :: _ when typeName.IsNotAClassifier -> (name, false, 1)
| "new" :: name :: _ when typeName.IsNotAClassifier -> (name, false, 2)
| _ -> raise(Exception("Property name is missing"))
let p = Property(name, visibility, typeName, mustInit)
Угадайте зачем нужен skipCount. На самом деле он нужен чтобы «отмотать» несколько первых элементов списка args для последующей обработки. Сама отмотка реализована достаточно брутально:
let rec skipElements count theList = match theList with | h :: t when count > 0 -> skipElements (count-1) t | _ -> theList
Думаете что theList должен быть первым параметром? А вот и нет. Если определить его именно так, можно получить частичное применение «огрызка» нашего списка токенов:
args |> skipElements 2 |> buildStructure
Вот собственно зачем нам был нужен skipElements.
Заключение
Интересно почему обработка строк и массивов всегдя тянет меня на F# – ведь в нем море проблем в которых можно утонуть и, что самое главное, когда пишешь код в нефункциональном стиле, написание занимает в 4 раза больше времени чем на C#. Возможно имеет больший смысл писать обработку на кастомном DSL, предназначенном именно для таких задач. А на чем писать такой DSL? Возможно на F#.
Я обновил проект, так что если интересно, весь описанный в этом посте F# код лежит в репозитарии.
Интересное наблюдение: после нескольких часов мучений с F# чувствую себя не героем а идиотом. К чему бы это? ■

насчет mutable nullable – это конечно жестокий код, но ты точно уверен что это единственный выход? Все это для F# настолько инородное, что мне почему-то кажется, должен быть более functional-style реализовать этот парсинг.А почему бы не передавать визиторы как параметры в buildStructure?.
Meroving
27 Март 2010 в 11:01
Тут есть варианты. Вообще по-хорошему нужно декларативно описывать все структуры и парсер создавать соответственно. Но я почему-то поленился все это изучить. Впрочем, уже все написано и работает, так что нет смысла горевать :)
Dmitri
27 Март 2010 в 13:19