Статьи

Рефакторинг с помощью циклов и конвейеров сбора: часть 1

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

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

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


Рефакторинг простого цикла в конвейер

Я начну с простого примера цикла и покажу основной способ его преобразования в конвейер сбора.

Давайте представим, что у нас есть список авторов, каждый из которых имеет следующую структуру данных.

Автор класса …

  public string Name { get; set; }
  public string TwitterHandle { get; set;}
  public string Company { get; set;}

В этом примере используется C #

Вот петля.

Автор класса …

  static public IEnumerable<String> TwitterHandles(IEnumerable<Author> authors, string company) {
    var result = new List<String> ();
    foreach (Author a in authors) {
      if (a.Company == company) {
        var handle = a.TwitterHandle;
        if (handle != null)
          result.Add(handle);
      }
    }
    return result;
  }

Мой первый шаг в рефакторинге цикла в конвейер коллекции — это применить Extract Variable к коллекции циклов.

Автор класса …

  static public IEnumerable<String> TwitterHandles(IEnumerable<Author> authors, string company) {
    var result = new List<String> ();
    var loopStart = authors;
    foreach (Author a in loopStart) {
      if (a.Company == company) {
        var handle = a.TwitterHandle;
        if (handle != null)
          result.Add(handle);
      }
    }
    return result;
  }

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

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

Автор класса …

  static public IEnumerable<String> TwitterHandles(IEnumerable<Author> authors, string company) {
    var result = new List<String> ();
    var loopStart = authors
      .Where(a => a.Company == company);
    foreach (Author a in loopStart) {
      if (a.Company == company) {
        var handle = a.TwitterHandle;
        if (handle != null)
          result.Add(handle);
      }
    }
    return result;
  }

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

Автор класса …

  static public IEnumerable<String> TwitterHandles(IEnumerable<Author> authors, string company) {
    var result = new List<String> ();
    var loopStart = authors
      .Where(a => a.Company == company)
      .Select(a => a.TwitterHandle);
    foreach (string handle in loopStart) {
      var handle = a.TwitterHandle;
      if (handle != null)
        result.Add(handle);
     }
    return result;
  }

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

Автор класса …

  static public IEnumerable<String> TwitterHandles(IEnumerable<Author> authors, string company) {
    var result = new List<String> ();
    var loopStart = authors
      .Where(a => a.Company == company)
      .Select(a => a.TwitterHandle)
      .Where (h => h != null);
    foreach (string handle in loopStart) {
      if (handle != null)
        result.Add(handle);
    }
    return result;
  }

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

Автор класса …

  static public IEnumerable<String> TwitterHandles(IEnumerable<Author> authors, string company) {
    var result = new List<String> ();
    return authors
      .Where(a => a.Company == company)
      .Select(a => a.TwitterHandle)
      .Where (h => h != null);
    foreach (string handle in loopStart) {
      result.Add(handle);
    }
    return result;
  }

Вот окончательное состояние кода

Автор класса …

  static public IEnumerable<String> TwitterHandles(IEnumerable<Author> authors, string company) {
    return authors
      .Where(a => a.Company == company)
      .Select(a => a.TwitterHandle)
      .Where (h => h != null);
  }

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

Furthermore, this style of code is familiar even in different languages who have different syntaxes and different names for pipeline operators.

Java

  public List<String> twitterHandles(List<Author> authors, String company) {
    return authors.stream()
            .filter(a -> a.getCompany().equals(company))
            .map(a -> a.getTwitterHandle())
            .filter(h -> null != h)
            .collect(toList());
  }

Ruby

  def twitter_handles authors, company
    authors
      .select {|a| company == a.company}
      .map    {|a| a.twitter_handle}
      .reject {|h| h.nil?}
  end

while this matches the other examples, I would replace the final reject with compact

Clojure

  (defn twitter-handles [authors company]
    (->> authors
         (filter #(= company (:company %)))
         (map :twitter-handle)
         (remove nil?)))

F#

  let twitterHandles (authors : seq<Author>, company : string) = 
       authors
           |> Seq.filter(fun a -> a.Company = company)
           |> Seq.map(fun a -> a.TwitterHandle)
           |> Seq.choose (fun h -> h)

again, if I wasn’t concerned about matching the structure of the other examples I would combine the map and choose into a single step

I’ve found that once I got used to thinking in terms of pipelines I could apply them quickly even in an unfamiliar language. Since the fundamental approach is the same it’s relatively easy to translate from even unfamiliar syntax and function names.

Refactoring within the Pipeline, and to a Comprehension

Once you have some behavior expressed as a pipeline, there are potential refactorings you can do by reordering steps in the pipeline. One such move is that if you have a map followed by a filter, you can usually move the filter before the map like this.

class Author…

  static public IEnumerable<String> TwitterHandles(IEnumerable<Author> authors, string company) {
    return authors
      .Where(a => a.Company == company)
      .Where (a => a.TwitterHandle != null)
      .Select(a => a.TwitterHandle);
  }

When you have two adjacent filters, you can combine them using a conjunction.

class Author…

  static public IEnumerable<String> TwitterHandles(IEnumerable<Author> authors, string company) {
    return authors
      .Where(a => a.Company == company && a.TwitterHandle != null)
      .Select(a => a.TwitterHandle);
  }

Once I have a C# collection pipeline in the form of a simple filter and map like this, I can replace it with a Linq expression

class Author…

  static public IEnumerable<String> TwitterHandles(IEnumerable<Author> authors, string company) {
    return from a in authors where a.Company == company && a.TwitterHandle != null select a.TwitterHandle;
  }

I consider Linq expressions to be a form of list comprehension, and similarly you can do something like this with any language that supports list comprehensions. It’s a matter of taste whether you prefer the list comprehension form, or the pipeline form (I prefer pipelines). In general pipelines are more powerful, in that you can’t refactor all pipelines into comprehensions.