Работающий программист

Мультипарадигматическая .NET. Часть 8: функциональное программирование

Тэд Ньюард

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

В одной из предыдущих статей в июньском номере (msdn.microsoft.com/magazine/hh205754) для решения некоторых интересных задач проектирования мы под микроскопом изучали идею обеспечения вариативности по оси имен через соглашения по именованию и динамическое программирование, т. е. используя связывание по имени, которое в .NET обычно подразумевает применение отражения на каком-то уровне. Большинство .NET-разработчиков, как мне представляется, ожидает, что основная часть динамического программирования увязана на ключевое слово dynamic в C# 4. Однако давним пользователям Visual прекрасно известно, что C# пришел к своему динамизму совсем недавно, тогда как в Visual Basic он используется — и весьма успешно во многих случаях — уже не одно десятилетие.

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

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

Представьте, что вам дали проектировочное задание — создать небольшой калькулятор командной строки: пользователь вводит (или конвейеризует) математическое выражение, и калькулятор разбирает его, а потом выводит результат. Запрограммировать его достаточно легко, как показано на рис. 1.

рис. 1. Простой калькулятор

class Program
{
  static void Main(string[] args)
  {
    if (args.Length < 3)
      throw new Exception(
        "Must have at least three command-line arguments");

    int lhs = Int32.Parse(args[0]);
    string op = args[1];
    int rhs = Int32.Parse(args[2]);
    switch (op)
    {
      case "+": Console.WriteLine("{0}", lhs + rhs); break;
      case "-": Console.WriteLine("{0}", lhs - rhs); break;
      case "*": Console.WriteLine("{0}", lhs * rhs); break;
      case "/": Console.WriteLine("{0}", lhs / rhs); break;
      default:
        throw new Exception(String.Format(
          "Unrecognized operator: {0}", op));
    }
  }
}

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

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

Рис. 2. Более универсальный калькулятор

 

class Program
{
  static void Main(string[] args)
  {
    if (args.Length < 3)
      throw new Exception(
        "Must have at least three command-line arguments");

    int lhs = Int32.Parse(args[0]);
    string op = args[1];
    int rhs = Int32.Parse(args[2]);
    Console.WriteLine("{0}", Operate(lhs, op, rhs));
  }
  static int Operate(int lhs, string op, int rhs)
  {
    // ...
  }
}

Очевидно, мы могли бы просто воссоздать блок switch/case в Operate, но это не дало бы особого выигрыша. В идеале, нам нужна была бы некая разновидность поиска «строка — операция» (что на первый взгляд вновь является формой динамического программирования, например связывание имени «+» с операцией сложения).

В мире проектировочных шаблонов это был бы случай для применения шаблона Strategy, где конкретные подклассы реализуют базовый класс или интерфейс, предоставляя необходимые сигнатуру и проверку типов на этапе компиляции для безопасности, — нечто наподобие таких строк:

interface ICalcOp
{
  int Execute(int lhs, int rhs);
}
class AddOp : ICalcOp {
  int Execute(int lhs, int rhs) { return lhs + rhs; } }

Которые работают… вроде бы. Это довольно многословно и требует создания нового класса для каждой новой операции. Кроме того, это не слишком объектно-ориентированно, так как на самом деле нам нужен лишь один экземпляр, размещенный в таблице поиска для проверки на совпадение и выполнения:

private static Dictionary<string, ICalcOp> Operations;
static int Operate(int lhs, string op, int rhs)
{
  ICalcOp oper = Operations[op];
  return oper.Execute(lhs, rhs);
}

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

delegate int CalcOp(int lhs, int rhs);
static Dictionary<string, CalcOp> Operations =
  new Dictionary<string, CalcOp>();
static int Operate(int lhs, string op, int rhs)
{
  CalcOp oper = Operations[op];
  return oper(lhs, rhs);
}

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

static Program()
{
  Operations["+"] = delegate(int lhs, int rhs) { return lhs + rhs; }
}

Смекалистые программисты на C# 3 сразу же сообразят, что это можно сократить еще больше, используя лямбда-выражения, введенные в эту версию языка. Visual Basic в Visual Studio 2010 способен на нечто подобное:

static Program()
{
  Operations["+"] = (int lhs, int rhs) => lhs + rhs;
}

На этом идеи большинства разработчиков на C# и Visual Basic по поводу применения делегатов и лямбда-выражений исчерпываются. Но лямбда-выражения и делегаты куда интереснее, особенно когда мы начинаем расширять эту идею с передачей функций и использования их самыми разнообразными способами.

Сокращения, сопоставления и свертывания — о, боже!

Передача функций — это не то, к чему мы привыкли в мейнстримовой .NET-разработке, поэтому нужен более конкретный пример того, какие преимущества это дает.

Допустим, что у нас есть набор объектов Person, как показано на рис. 3..

Рис. 3. Набор объектов Person

class Person
{
  public string FirstName { get; set; }
  public string LastName { get; set; }
  public int Age { get; set; }
}

class Program
{
  static void Main(string[] args)
  {
    List<Person> people = new List<Person>()
    {
      new Person() { FirstName = "Ted", LastName =
        "Neward", Age = 40 },
      new Person() { FirstName = "Charlotte", LastName =
        "Neward", Age = 39 },
      new Person() { FirstName = "Michael", LastName =
        "Neward", Age = 17 },
      new Person() { FirstName = "Matthew", LastName =
        "Neward", Age = 11 },
      new Person() { FirstName = "Neal", LastName =
        "Ford", Age = 43 },
      new Person() { FirstName = "Candy", LastName =
        "Ford", Age = 39 }
    };
  }
}

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

Рис. 4. Традиционный цикл foreach

static void Main(string[] args)
{
  List<Person> people = new List<Person>()
  {
    new Person() { FirstName = "Ted", LastName =
      "Neward", Age = 40 },
    new Person() { FirstName = "Charlotte", LastName =
      "Neward", Age = 39 },
    new Person() { FirstName = "Michael", LastName =
      "Neward", Age = 17 },
    new Person() { FirstName = "Matthew", LastName =
      "Neward", Age = 11 },
    new Person() { FirstName = "Neal", LastName =
      "Ford", Age = 43 },
    new Person() { FirstName = "Candy", LastName =
      "Ford", Age = 39 }
  };
  foreach (var p in people)
    Console.WriteLine("Have a beer, {0}!", p.FirstName);
}

Здесь есть несколько мелких ошибок (в основном они сводятся к тому, что код разрешает выдать пиво моему одиннадцатилетнему сыну), но в чем заключается самая крупная проблема с этим кодом? Он изначально не является повторно используемым. Последующие попытки выдать пиво кому-нибудь еще неизбежно потребуют другого цикла foreach, которые нарушают принцип DRY (Don’t Repeat Yourself) («Не повторяйся»). Конечно, мы могли бы собрать код, отвечающий за раздачу пива, в какой-нибудь метод (классическая реакция на общность в процедурном программировании):

static void GiveBeer(List<Person> people)
{
  foreach (var p in people)
    if (p.Age >= 21)
      Console.WriteLine("Have a beer, {0}!", p.FirstName);
}

(Заметьте, что я добавил проверку на возраст более 21 года; моя жена, Шарлотта, настояла, чтобы я включил такую проверку перед тем, как отправлять статью.) Ну а если нужно искать кого-либо, кто старше 16 лет, чтобы выдавать им вместо пива билет в кино? Или найти тех, кому за 39, и раздать им плакаты «Боже мой, да ты совсем старик!»? Или отыскать людей в возрасте от 65 лет и подарить им маленькие ноутбуки, чтобы они записывали те вещи, о которых предпочли бы забыть (имя, возраст, адрес…)?

Чем больше примеров мы придумаем, тем яснее нам станет, что каждый из этих случаев представляет два элемента вариативности: фильтрацию объектов Person и операцию, выполняемую над каждым из этих объектов. Учитывая мощь делегатов (и типов Action<T> и Predicate<T>, появившихся в .NET 2.0), можно создать общность, в то же время обеспечив необходимую вариативность, как показано на рис. 5.

Рис. 5. Фильтрация объектов Person

static List<T> Filter<T>(List<T> src, Predicate<T> criteria)
{
  List<T> results = new List<T>();
  foreach (var it in src)
    if (criteria(it))
      results.Add(it);
  return results;
}
static void Execute<T>(List<T> src, Action<T> action)
{
  foreach (var it in src)
    action(it);
}
static void GiveBeer(List<Person> people)
{
  var drinkers = Filter(people, (Person p) => p.Age >= 21);
  Execute(drinkers, (Person p) => Console.WriteLine(
    "Have a beer, {0}!", p.FirstName));
}

Одна из более общих операций — «преобразование» (или, точнее, «проецирование») объекта в другой тип, например когда мы хотим извлечь фамилии из списка объектов Person в список строк (рис. 6).

Рис. 6. Преобразование из списка объектов в список строк

public delegate T2 TransformProc<T1,T2>(T1 obj);
static List<T2> Transform<T1, T2>(List<T1> src,
  TransformProc<T1, T2> transformer)
{
  List<T2> results = new List<T2>();
  foreach (var it in src)
    results.Add(transformer(it));
  return results;
}
static void Main(string[] args)
{
  List<Person> people = // ...

  List<string> lastnames = Transform(people,
    (Person p) => p.LastName);
  Execute(lastnames, (s) => Console.WriteLine(
    "Hey, we found a {0}!", s);
}

Заметьте, что благодаря использованию обобщений в объявлениях Filter, Execute и Transform (больше общности/вариативности!) можно повторно применять Execute для отображения каждой из найденных фамилий. Также обратите внимание на то, что начинает вырисоваться интересное следствие применения лямбда-выражений; оно станет очевиднее, когда мы напишем еще одну распространенную функциональную операцию, сокращение (reduce), которая «сворачивает» набор в одно значение путем специфического объединения всех значений. Например, мы могли бы суммировать возраст каждого человека, чтобы получить значение с суммарным возрастом, используя цикл foreach так:

int seed = 0;
foreach (var p in people)
  seed = seed + p.Age;
Console.WriteLine("Total sum of everybody's ages is {0}",
  seed);

Или сделать то же самое, используя обобщенное сокращение, как показано на рис. 7.

Рис. 7. Применение обобщенного сокращения

public delegate T2 Reducer<T1,T2>(T2 accumulator, T1 obj);
static T2 Reduce<T1,T2>(T2 seed, List<T1> src,
  Reducer<T1,T2> reducer)
{
  foreach (var it in src)
    seed = reducer(seed, it);
  return seed;
}
static void Main(string[] args)
{
  List<Person> people = // ...

  Console.WriteLine("Total sum of everybody's ages is {0}",
    Reduce(0, people, (int current, Person p) =>
    current + p.Age));
}

Кстати, эту операцию сокращения часто называют свертыванием (fold). (Для проницательного функционального программиста эти два термина имеют немного разный смысл, но эта разница не критична в нашем случае.) И да, если вы начали подозревать, что эти операции на самом деле были тем же, что LINQ предоставляет для объектов (функциональность LINQ-to-Objects, которую при первоначальном выпуске приняли без особого восторга), то попали в точку (рис. 8).

Рис. 8. Операции свертывания

static void Main(string[] args)
{
  List<Person> people = new List<Person>()
  {
    new Person() { FirstName = "Ted", LastName =
      "Neward", Age = 40 },
    new Person() { FirstName = "Charlotte", LastName =
      "Neward", Age = 39 },
    new Person() { FirstName = "Michael", LastName =
      "Neward", Age = 17 },
    new Person() { FirstName = "Matthew", LastName =
       "Neward", Age = 11 },
    new Person() { FirstName = "Neal", LastName =
       "Ford", Age = 43 },
    new Person() { FirstName = "Candy", LastName =
       "Ford", Age = 39 }
  };
  // Фильтруем и выдаем пиво
  foreach (var p in people.Where((Person p) => p.Age >= 21))
    Console.WriteLine("Have a beer, {0}!", p.FirstName);

  // Выводим фамилии
  foreach (var s in people.Select((Person p) => p.LastName))
    Console.WriteLine("Hey, we found a {0}!", s);

  // Получаем суммарный возраст
  Console.WriteLine("Total sum of everybody's ages is {0}",
    people.Aggregate(0, (int current, Person p) =>
    current + p.Age));
}

Для корпоративного .NET-программиста-работяги все это кажется глупым. Настоящие программисты не тратят время на поиски способов повторного использования кода для суммирования возрастов. Настоящие программисты пишут код, который перебирает набор объектов, сцепляя первое имя из каждого объекта в XML-представление внутри строки, пригодное для использования в запросе OData или в чем-то другом:

Console.WriteLine("XML: {0}", people.Aggregate("<people>",
  (string current, Person p) =>
    current + "<person>" + p.FirstName + "</person>")
  + "</people>");

Ой, наверное, в итоге LINQ-to-Object все же может оказаться полезной.

Более функциональный?

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

Вспомните, что при классической ориентации на объекты вариативность проявляется на структурном уровне, позволяя создавать позитивную вариативность добавлением полей и методов и заменой существующих методов (через переопределение), но не дает возможности выделять специфическое алгоритмическое поведение. По сути, использование этой оси общности/вариативности стало возможным лишь после появления в .NET анонимных методов. Мы получили возможность делать нечто подобное в C# 1.0, но в каждом лямбда-выражении должен был присутствовать именованный метод, объявленный где-либо в другом месте, и каждый такой метод типизировался в рамках System.Object (что подразумевало приведение внутри этих методов), так как в C# 1.0 не было параметризованных типов.

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

Что важнее, разработчикам, желающим узнать больше о функциональном программировании, потребуется долго и упорно изучать F#, который является единственных изо всех .NET-языков, позволяющим использовать эти концепции функционального программирования (частичное применение и карринг) как языковые средства первого класса. Разработчики на C# и Visual Basic могут делать аналогичные вещи, но для этого им понадобится помощь со стороны дополнительных библиотек (с новыми типами и методами, делающими то, что в F# возможно изначально). К счастью, усилия в разработке таких библиотек сейчас предпринимаются, в том числе на CodePlex доступна библиотека «Functional C#» (functionalcsharp.codeplex.com).

Различные парадигмы

Нравится вам это или нет, мультипарадигматические языки сейчас широко применяются, и все идет к тому, что они останутся. В каждом из языков Visual Studio 2010 присутствует та или иная доля различных парадигм; в C++, например, есть некоторые механизмы параметрического метапрограммирования, нереализуемые в управляемом коде. Это стало возможным благодаря тому, как работает компилятор C++, а совсем недавно (в новейшем стандарте C++0x) этот язык стал поддерживать лямбда-выражения. Даже такие языки, как ECMAScript/JavaScript/JScript, которые нередко высмеивают, позволяют работать с объектами, процедурами, метапрограммированием, динамической и функциональной парадигмами; фактически большая часть JQuery построена на этих идеях.

Удачи в кодировании!


Тэд Ньюард (Ted Neward) ) — глава независимой компании Neward and Associates, специализирующейся на гибких и надежных корпоративных системах с применением .NET и Java. Автор и соавтор многочисленных книг, в том числе «Professional F# 2.0» (Wrox, 2010), более сотни статей, лектор INETA, часто выступает на многих конференциях по всему миру; кроме того, имеет звание Microsoft MVP в области C#. С ним можно связаться по адресу ted@tedneward.com или через блог blogs.tedneward.com.

Выражаю благодарность за рецензирование статьи эксперту Мэтью Подвысоки (Matthew Podwysocki).