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

Мультипарадигматическая .NET. Часть 9: выбор подхода

Тэд Ньюард

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

Тем не менее вести с полей четко доносят, что, несмотря на опасность вступления на двухзначную территорию, нужна еще одна статья по мультипарадигматическому проектированию — чтобы попытаться связать все элементы воедино, продемонстрировать, как использовать каждую из парадигм и выбрать подходящие для решения реальных задач. В нашем контексте «реальные» означают достаточно нетривиальные, где мультипарадигматический подход может оказаться действительно полезным, а не столь простые, как примеры в журнальной статье.

Придумать такую задачу труднее, чем может показаться поначалу: то она слишком сложна и требует отвлекаться на множество моментов, не позволяющих получить четкое представление о примененных решениях, то чересчур проста, чтобы можно было на ее примере проиллюстрировать использование разных парадигм. К счастью, мы можем воспользоваться уже проделанной работой — в виде «Каты кода» за авторством Дэйва Томаса (Dave Thomas).

Каты кода

На своем веб-сайте (codekata.pragprog.com), Томас пишет о том, как в один из вечеров отвел сына на тренировку по каратэ и обнаружил, что в помещении для родителей (откуда можно наблюдать за тренировкой детей) не осталось свободного места. Чтобы занять себя чем-то на эти 45 минут, он начал забавляться с кодом, о влиянии которого на производительность иногда задумывался. Томас пишет:

Я просто хотел поэкспериментировать кое с каким кодом и опробовать методику, которой еще не пользовался. Я делал это в простой управляемой среде и проверил много всяких вариаций (больше, чем перечислил здесь). И пока не добрался до конца…

Что дал мне этот неожиданный перерыв, пока мой сын был на тренировке? Что ж, у меня появилось какое-то время, когда меня никто не дергал. Я хотел попробовать простую вещь. Каждый раз я смотрел, что получается, чтобы улучшить код. Меня ничто не обременяло: этот код не входил ни в какие приложения. Это было игрой: я продвигался вперед маленькими шажками, что и подстегивало меня продолжать. Наконец, я закончил эту игру, зная больше, чем тогда, когда начал ее.

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

Итак, ставлю вопрос: подумайте, сумеете ли вы выкроить минут 45–60 на эксперименты с каким-нибудь небольшим кусочком кода? Не обязательно смотреть именно на производительность; возможно, вам важнее структура, объем занимаемой памяти или интерфейс. В конце концов это не имеет значения. Экспериментируйте, замеряйте, улучшайте.

Другими словами, ката кода — это (относительно) простая задача, которую нетрудно понять концептуально и которая предлагает инфраструктуру для исследования. В нашем конкретном случае целью будет проектирование. Мы будем исходить из исследований Томаса «Kata Four: Data Munging».

Ката номер четыре: разбор данных

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

Этап 1

Содержание этапа 1 следующее.

В файле weather.dat (http://bit.ly/ksbVPs) вы найдете ежедневные метеоданные за июнь 2002 года для Морристауна (Morristown), штат Нью-Джерси. Загрузите этот текстовый файл, потом напишите программу для вывода дня месяца (первый столбец) с минимальным разбросом температур (максимальная температура — второй столбец, минимальная — третий).

Файл weather.dat выглядит так, как показано в Рис. 1 (с тем исключением, что он охватывает все 30 дней).

Рис1 Текстовый файл Weather.dat

MMU June 2002                                
Dy MxT MnT AvT HDDay AvDP 1HrP TPcpn WxType PDir AvSp Dir MxS SkyC MxR MnR AvSLP
1 88 59 74   53.8   0.00 F 280 9.6 270 17 1.6 93 23 1004.5
2 79 63 71   46.5   0.00   330 8.7 340 23 3.3 70 28 1004.5
3 77 55 66   39.6   0.00   350 5.0 350 9 2.8 59 24 1016.8
4 77 59 68   51.1   0.00   110 9.1 130 12 8.6 62 40 1021.1
. ..                              
28 84 68 76   65.6   0.00 RTFH 280 7.6 340 16 7.0 100 51 1011.0
29 88 66 77   59.7   0.00   040 5.4 020 9 5.3 84 33 1020.6
30 90 45 68   63.6   0.00 H 240 6.0 220 17 4.8 200 41 1022.7
mo 82.9 60.5 71.7 16 58.8   0.00     6.9     5.3      

 

Сразу видно, что файл разделен не запятыми, а по позициям: столбец MxT (макс. температура) всегда начинается в одном и том же месте, а столбец MnT (мин. температура) — в другом фиксированном месте. Просмотр файла в Visual Studio открывает нам, что каждая строка имеет длину ровно 90 символов; задача разбора этого файла по строкам и цепочкам символов будет тривиальной, так как разбиение можно осуществлять по символу новой строки или группами по 90 символов.

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

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

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

Пока что здесь ничто не предполагает использования на практике тактики метапрограммирования, или динамического программирования, кроме, возможно, структуры данных, допускающей некую разновидность поиска по имени столбца, например day1["MxT"] возвращал бы «88». Функциональный подход мог бы быть интересен, так как процесс разбора можно представить набором функций, выполняемых одна за другой, которые принимают вывод и возвращают строки (или другие разобранные данные), а прочие функции разбирают остальное содержимое файла как результаты. Такая методика известна как комбинаторы функциональности разбора (parser combinators), но ее обсуждение выходит далеко за рамки данной статьи.

Ни один из этих подходов не дает особого выигрыша; на данный момент лучшим решение кажется процедурный подход рис. 2.

Рис. 2. Процедурный подход

namespace DataMunger
{
  public struct WeatherData
  {
    public int Day;
    public float MxT;
    public float MnT;
    // И остальные столбцы...
  }
  class Program
  {
    static void Main(string[] args)
    {
      TextReader reader = new StreamReader("weather.dat");

      // Пропускаем первые четыре строки
      reader.ReadLine();
      reader.ReadLine();
      reader.ReadLine();
      reader.ReadLine();

      // Начинаем считывать данные
      List<WeatherData> weatherInfo = new List<WeatherData>();
      while (reader.Peek() > 0)
      {
        string line = reader.ReadLine();

        // Защищаемся от итоговой строки "mo"
        if (line.Substring(0, 4) == "  mo")
          continue;

        WeatherData wd = new WeatherData();
        wd.Day = Int32.Parse(line.Substring(0, 4).
          Replace("*", " "));
        wd.MxT = Single.Parse(line.Substring(5, 6).
          Replace("*", " "));
        wd.MnT = Single.Parse(line.Substring(12, 6).
          Replace("*", " "));
        // Разбор остальных данных...

        weatherInfo.Add(wd);
      }
      Console.WriteLine("Max spread: " +
        weatherInfo.Select((wd) => wd.MxT - wd.MnT).Max());
    }
  }
}

Заметьте, что этот код уже представляет собой проблему: когда процесс разбора доходит до девятого дня, в столбце MnT появляется звездочка, указывающая, что эта температура является низкой (low) для данного месяца; аналогично помечается 26-й день, где температура высокая (high) для месяца. Эта проблема решается простым удалением символа * из строки, но суть в том, что на процедурной оси основное внимание уделяется идентификации структуры данных и операциям над ней, в данном случае ее разбору.

Кроме того, обратите внимание на использование здесь List<>; только из-за того, что мы используем процедурный подход для разбора файла, еще не означает, что мы не можем задействовать преимущества полезных классов из Base Class Library. Благодаря этому определение минимального разброса температур становится тривиальной задачей — единственный запрос LINQ to Objects даст нужный результат. (Конечно, если честно решать задачу, нам следовало бы получать день, в который наблюдается такой разброс, но это на деле сводится к возврату и применению Max() к чему-то, отличному от простых колебаний; считайте это заданием на дом.)

Этап 2

Мы могли бы потратить массу времени, обдумывая, как написать «повторно используемый» код для этой задачи, но такие попытки сродни предсказанию будущего; этот код работает, значит, идем дальше и переходим к следующей части каты:

В файле football.dat (bit.ly/ksbVPs) хранятся результаты игр в английской премьер-лиге в сезоне 2001–2002 гг. Столбцы, помеченные «F» и «A», содержат общее количество забитых и пропущенных голов по каждой команде в этом сезоне (таким образом, команде «Arsenal» засчитано 79 забитых и 36 пропущенных голов). Напишем программу, которая выводит название команды с минимальной разницей между забитыми («for») и пропущенными («against») голами.

Снова разбор текста. Наслаждайтесь. Содержимое файла football.dat приведено в Рис. 3; во многих отношениях оно аналогично содержимому weather.dat (Рис. 1), тем не менее разница достаточно ощутима, чтобы вынудить нас писать несколько иной код разбора.

Рис. 3 Текстовый файл Football.dat

  Team P W L D F  
1. Arsenal 38 26 9 3 79 -
2. Liverpool 38 24 8 6 67 -
3. Manchester_U 38 24 5 9 87 -
4. Newcastle 38 21 8 9 74 -
5. Leeds 38 18 12 8 53 -
6. Chelsea 38 17 13 8 66 -
7. West_Ham 38 15 8 15 48 -
8. Aston_Villa 38 12 14 12 46 -
9. Tottenham 38 14 8 16 49 -
10. Blackburn 38 12 10 16 55 -
11. Southampton 38 12 9 17 46 -
12. Middlesbrough 38 12 9 17 35 -
13. Fulham 38 10 24 14 36 -
14. Charlton 38 10 14 14 38 -
15. Everton 38 11 10 17 45 -
16. Bolton 38 9 13 16 44 -
17. Sunderland 38 10 10 18 29 -
18. Ipswich 38 9 9 20 41 -
19. Derby 38 8 6 24 33 -
20. Leicester 38 5 13 20 30 -

 

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

Этап 3

Последняя часть этой каты, однако, ведет нас к сути упражнения.

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

Вот это уже интересно, и мы можем приступить к анализу общности и вариативности.

Общность и вариативность

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

При разборе текстовых файлов нужно учесть несколько моментов:

  • в обоих файловых форматах есть «заголовок», который нужно пропускать;
  • оба файловых формата основаны на позициях;
  • в обоих файловых форматах есть строки, которые нужно игнорировать (сводная строка «mo» в weather.dat и визуальный маркер «------» в football.dat);
  • в файле weather.dat встречаются пустые столбцы, а в файле football.dat таких нет;
  • оба файловых формата поддерживают числовые и строковые поля (кроме того, weather.dat также включает значения «*», которые требуется каким-то образом захватывать).

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

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

Объектно-ориентированный Общность между двумя файлами предполагает использование базового абстрактного класса TextParser, обеспечивающего базовую функциональность разбора, в том числе возможность пропуска каких-то строк. Вариативность проявляется при разборе каждой строки, а значит, подклассы должны переопределять некую форму метода ParseLine для выполнения построчного разбора. Однако то, как разобранные значения будут извлекаться из подтипа TextParser (чтобы осуществлять сравнение минимумов и максимумов), может оказаться делом непростым, так как типы полей (столбцов) тоже будут варьироваться. В Microsoft .NET Framework эта проблема (с наборами данных SQL) решается возвратом объектов, которые мы могли бы при необходимости использовать. Но это вводит риск ошибок, связанных с безопасностью типов, потому что объекты пришлось бы приводить к другим типам, а это опасно.

Мета В диапазоне мета-объектного или метапрограммирования можно подобрать несколько решений. Атрибутивный подход предполагал бы, что класс TextParser принимает тип Record, в котором каждое описываемое поле имеет собственный атрибут начала/длины, указывающий, как разбирать строки, например:

public struct WeatherData
{
  [Parse(0, 4)]
  public int Day;
  [Parse(5, 6)]
  public float MxT;
  [Parse(12, 6)]
  public float MnT;
}

TextParser можно было бы параметризовать для приема типа Record (TextParser <RecordT>) и использовать собственные атрибуты в период выполнения, чтобы определять, как разбирать каждую строку. Затем мы возвращали бы список записей (List<RecordT>), после чего можно было бы выполнять ранее упомянутые вычисления.

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

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

string parseCommands =
  "Ignore; Ignore; Ignore; Ignore; Repeat(Day:0-4, MxT:5-6,
  MnT:12-6)";
TextParser parser = new TextParser(parseCommands);
List<string> MxTData = parser["MxT"];
List<string> MnTData = parser["MnT"];

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

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

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

Рис. 4. Функциональный подход

TextParser<WeatherData> parser = new TextParser<WeatherData>();
parser.LineParseVerifier =
  (line) =>
  {
    if ( (line.Trim().Length == 0) || // пустая строка
         (line.Contains("MMU")) ||    // первый заголовок
         (line.Contains("Dy")) ||     // второй заголовок
         (line.Contains("mo")))
        return false;
    else
      return true;
  };
parser.ColumnExtracter =
  (line) =>
  {
    WeatherData wd = new WeatherData();
    wd.Day = line.Substring(0, 4);
    wd.MxT = line.Substring(5, 6);
    wd.MnT = line.Substring(12, 6);
    return wd;
  };
List<WeatherData> results = parser.Parse("weather.dat");

TextParser использует параметризованный тип исключительно для охвата типа WeatherData, обеспечивая более высокую безопасность типов; при желании его можно было бы написать так, чтобы он возвращал обобщенный System.Object.

Заключение

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

Мультипарадигматическое проектирование — предмет, непростой в понимании, во всяком случае не за один присест. Чтобы описать его, потребовалось 9 статей, и вполне разумно ожидать, что его принятие вами займет куда больше времени, чем чтение этих статей. Но в итоге вы можете со временем прийти к гораздо более гибким и удобным проектам и даже предлагать решения трудных задач, тогда как другие разработчики останутся в недоумении, как это вы умудрились разглядеть такое решение в полном тумане. Помните, что в конечном счете суть мультипарадигматического подхода заключается в поиске общности и вариативности. Учитывайте это, и многие вещи начнут сами отсортировываться в вашей голове.

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

Я уже говорил, но повторить не помешает: мультипарадигматические языки уже существуют, широко используются и все указывает на то, что они останутся с нами надолго. В каждом из языков 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 или через блог http://blogs.tedneward.com.

Выражаю благодарность за рецензирование статьи эксперту Энтони Д. Грину (Anthony D. Green).