Приемы программирования на Java

Повышение читаемости кода и функциональное программирование

Автор: Евгений Кирпичев aka jkff
Yandex

Источник: RSDN Magazine #3-2008
Опубликовано: 28.12.2008
Версия текста: 1.0
Введение
Увеличиваем читаемость данных
Увеличиваем читаемость комбинаторов
Увеличиваем читаемость анонимных классов
Увеличиваем читаемость имен классов
Увеличиваем читаемость generic-ов – typedef для бедных
Увеличиваем читаемость generic-ов-2, используем вывод типов Java
Увеличиваем отлаживаемость этого хозяйства
Прочитайте J vocabulary...
Карринг по последнему аргументу
Пары и бинарные отношения
Крупные комбинаторы
Заключение

Введение

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

Речь также пойдет о рефакторинге кода, неосознанно написанного в функциональном стиле (такого, как new AndFilter(new FieldMatchesPatternFilter(new FieldReference("name"), ".*John.*"), new BlaBlaBlaFilter())), в читаемую и лаконичную форму.

DISCLAIMER:

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

Вот некоторые из недостатков Java, с которыми мы будем бороться:

В качестве орудий пригодятся такие возможности Java 5, как import static, varargs – и немного пороха в пороховницах.

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

Итак, приступим.

Увеличиваем читаемость данных

Совсем плохо:

      private
      static
      final Set<String> INTERESTING_TAGS = 
    new HashSet<String>();
Map<String,Integer> WEIGHTS = new HashMap<String,Integer>

static {
  INTERESTING_TAGS.add("A");
  INTERESTING_TAGS.add("FORM");
  INTERESTING_TAGS.add("INPUT");
  INTERESTING_TAGS.add("SCRIPT");
  INTERESTING_TAGS.add("OBJECT");

  WEIGHTS.put("bad", -2);
  WEIGHTS.put("poor", -1);
  WEIGHTS.put("average", 0);
  WEIGHTS.put("nice", 1);
  WEIGHTS.put("outstanding", 3);
}

Плохо:

      private
      static
      final Set<String> INTERESTING_TAGS = 
  new HashSet<String>(Arrays.asList(new String[] 
  {"A","FORM","INPUT","SCRIPT","OBJECT"}));
for(int coinValue : new int[] {1, 2, 5, 10, 20, 50, 100}) 
{
  ...
}

Хорошо:

      public
      class CollectionUtils 
{
  publicstatic T[] ar(T... ts) {return ts;}

  publicstatic Set<T> set(T... ts) 
  {
    returnnew HashSet<T>(Arrays.asList(ts));
  }

  publicstatic Map<K,V> zipMap(K[] keys, V[] values) {...}
}

importstatic CollectionUtils.set;
importstatic CollectionUtils.ar;

privatestaticfinal Set<String> INTERESTING_TAGS = 
  set("A","FORM","INPUT","SCRIPT","OBJECT");
for(int coinValue : ar(1, 2, 5, 10, 20, 50, 100)) {
  ...
}

Map<String,Integer> WEIGHTS = zipMap(
  ar("bad", "poor", "average", "nice", "outstanding"),
  ar(-2,    -1,     0,         1,      3));

Казалось бы, как просто! Обыкновенная абстракция конструкторов данных, вторая глава классической библии программирования «Структура и интерпретация компьютерных программ» Абельсона и Сассмана.

Особенно ярко полезность упрощенного синтаксиса для создания данных проявляется в юнит-тестах, когда есть необходимость сконструировать для программы множество входных примеров.

В этом случае очень часто оправдано даже кодирование структур в строке и написание маленького парсера. Например:

assertTrue(GraphAnalyzer.isConnected(graph("1->2 2->3 3->1")));

Легко представить себе, как выглядела бы эта строчка без абстракции конструктора – последовательность конструкторов, объявлений переменных и вызовов вида .addVertex() и .addEdge().

Отсутствие объявляемых временных переменных, засоряющих область видимости – одно из основных преимуществ этих приемов.

Увеличиваем читаемость комбинаторов

Плохо:

Filter f = new AndFilter(first, second);

Хорошо:

      public
      abstract
      class Filters 
{
  publicstatic Filter and(Filter a, Filter b) {returnnew AndFilter(a,b);}
}

importstatic Filters.*;

Filter f = and(first,second);

Еще лучше:

      public
      abstract
      class Filter 
{
  Filter and(Filter other) {return Filters.and(this,other);}
}
Filter f = first.and(second).and(third);

Совсем хорошо:

      public
      abstract
      class Filters 
{
  publicstatic Filter ALWAYS_TRUE = new AlwaysTrue();

  publicstaticFilter and(Filter... filters) 
  {
    Filter res = ALWAYS_TRUE;
    for(Filter f : filters) res = res.and(f);
    return res;
  }
}

Filter f = and(first, second, third);

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

От клиентского кода требуется мгновенная читаемость, от библиотечного – читаемость при необходимости.

Еще один пример:

Плохо:

      enum StringComparisonKind {EXACT, REGEX, GLOB}
enum StringPosition {ANYWHERE, WHOLE_STRING, STARTS_WITH, ENDS_WITH}
publicclass StringCondition {
  ...

  public StringCondition(
String pattern, StringComparisonKind comparisonKind, 
    StringPosition position) {...}
}

conditions.add(new StringCondition(
  "foo", StringComparisonKind.REGEX, StringPosition.ANYWHERE))

Хорошо:

      import
      static StringComparisonKind.*;
importstatic StringPosition.*;
publicclass StringConditions {
    public static regexWhole(String regex) {
        return new StringCondition(regex, REGEX, WHOLE_STRING);
    }
    public static regexAnywhere(String regex) {
        return new StringCondition(regex, REGEX, ANYWHERE);
    }
    public static exactWhole(String pattern) {
        return new StringCondition(pattern, EXACT, WHOLE_STRING);
    }
    ...
}


importstatic StringConditions.*;
conditions.add(regexAnywhere("foo"));

Абстракция, абстракция и еще раз абстракция. Удивительно, насколько ее обычно недооценивают.

Увеличиваем читаемость анонимных классов

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

Плохо:

List<Order> orders = CollectionUtils.flatten(CollectionUtils.map(
  customers, new Function<Customer, List<Order>>() 
{
  public List<Order> apply(Customer customer) 
  {
    return customer.getOrders();
  }
}));

Хорошо:

      import
      static CollectionUtils.*;

private static final Function<Customer, List<Order>> GET_ORDERS = 
new Function<Customer, List<Order>>() 
{
  public List<Order> apply(Customer customer) 
  {
    return customer.getOrders();
  }
};

List<Order> orders = flatten(map(customers, GET_ORDERS));

(к сожалению, судя по всему, не существует способа избавиться от дублирования аргументов generic-ов).

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

Увеличиваем читаемость имен классов

Плохо:

CustomerProcessor taxes = new ComputeTaxesCustomerProcessor();

Эти суффиксы не дают вообще ничего. Если бы у Java не было пакетов (package) или статической типизации, то это было бы оправдано, чтобы не засорять глобальный namespace или случайно не перепутать один And с другим. Но они есть, и суффиксы не нужны – так же, как, например, венгерская нотация.

Хорошо:

CustomerProcessor taxes = new ComputeTaxes();

Увеличиваем читаемость generic-ов – typedef для бедных

Плохо:

      class FooEverythingDoer 
{
  ...
  Map<String, String> getProperties(Foo foo) {...}
  void putProperties(Foo foo, Map<String, String> properties) {...}
  Map<Foo, Map<String, String>> getPropertiesBatch(Iterable<Foo> foos) {...}
  Foo findByProperties(Map<String, String> partOfProperties) {...}
  ...
}

Хорошо:

      class Properties extends Map<String,String> 
{
  (Конструкторы с сигнатурами базового класса)
}


class FooEverythingDoer 
{
  ...
  Properties getProperties(Foo foo) {...}
  void putProperties(Foo foo, Properties properties) {...}
  Map<Foo, Properties> getPropertiesBatch(Iterable<Foo> foos) {...}
  Foo findByProperties(Properties partOfProperties) {...}
  ...
}

Плохо:

      class MagicBarMerger 
{
  publicvoid mergeIntoDb(List<MagicBar> bars) 
  {
    List<MagicBar> existingBars = barDao.getAllBars();
    Map<Integer, List<Pair<MagicBar, MagicBar>>> pairsById = 
      joinOnId(bars, existingBars);
    List<MagicBar> merged = new ArrayList<MagicBar>();
    for(Pair<MagicBar, MagicBar> pair : pairsById) 
    {
        merged.add(merge(pair));
    }
    barDao.removeAllBars():
    barDao.putBarsBatch(merged);
  }

  private Map<Integer, Pair<MagicBar, MagicBar>> joinOnId(
    List<MagicBar> as, List<MagicBar> bs) 
  {
    ....
  }

  private MagicBar merge(Pair<MagicBar, MagicBar> bars) 
  {
    ....
  }
}

Хорошо:

      class MagicBarMerger 
{
  private static class Bars extends Pair<MagicBar,MagicBar> 
  {
    (Конструктор с сигнатурой базового класса)
  }

  publicvoid mergeIntoDb(List<MagicBar> bars) 
  {
    List<MagicBar> existingBars = barDao.getAllBars();
    Map<Integer, Bars> pairsById = joinOnId(bars, existingBars);
    List<MagicBar> merged = new ArrayList<MagicBar>();
    for(Bars bars : pairsById) {
      merged.add(merge(bars));
  }
  barDao.removeAllBars():
  barDao.putBarsBatch(merged);
}

  private Map<Integer, Bars> joinOnId(List<MagicBar> as, List<MagicBar> bs) 
  {
    ....
  }

  private MagicBar merge(Bars bars) 
  {
    ....
  }
}

Этот прием отчасти решает и проблему читаемости анонимных классов с типовыми параметрами:

Плохо:

      import
      static CollectionUtils.*;

private static final Function<Customer, List<Order>> GET_ORDERS = 
new Function<Customer, List<Order>>() 
  {
    public List<Order> apply(Customer customer) 
    {
      return customer.getOrders();
    }
  };

Хорошо:

      interface CustomerFun<T> extends Function<Customer,T> {}
interface CustomerListFun<T> extends CustomerFun<List<T>> {}

importstatic CollectionUtils.*;

private static final CustomerListFun<Order> GET_ORDERS = 
  new CustomerListFun<Order>() 
  {
    public List<Order> apply(Customer customer) 
    {
      return customer.getOrders();
    }
  };

Увеличиваем читаемость generic-ов-2, используем вывод типов Java

Плохо:

Map<Integer, List<String>> namesById = new HashMap<Integer, List<String>>();

Хорошо:

      public
      class CollectionFactory 
{
  publicstatic <K,V> Map<K,V> newMap() 
  {
   returnnew HashMap<K,V>();
  }
}

importstatic CollectionUtils.newMap;
Map<Integer, List<String>> namesById = newMap();

Функции ar(T... ts) и set(T... ts) в правиле 1 – из этой же оперы.

Вполне можно в класс CollectionFactory положить обертки для всех конструкторов традиционных коллекций, а конструкторы Map'ов и Set'ов назвать не просто newMap/newSet, а соответственно newUnorderedMap/newLinkedMap и newUnorderedSet/newLinkedSet, тем самым сделав акцент на наличии или отсутствии требования упорядоченности и раз и навсегда избавив себя от связанных с этим проблем.

Практика показывает, что этот прием, будучи тайком внедренным в общую библиотеку, приживается среди коллег легче всего :)

Увеличиваем отлаживаемость этого хозяйства

В отладчике неприятно бывает увидеть, забравшись в кишки объекту, что его класс называется FooProcessor$3$1, а у его полей с именами innerProcessor и value – класс FooProcessor$4$2 и значение "1".

Поэтому следует все классы, объекты которых "уплывают" из локальной области видимости метода (а таких абсолютное большинство, согласно правилу №3), делать неанонимными и давать им осмысленные имена. Еще лучше реализовывать в классе метод toString, отображающий внутреннее состояние объекта. Это совсем недолго, а при отладке помогает неимоверно. Кроме того, избегание анонимных классов уменьшает число проблем с сериализацией.

Комбинаторы высшего порядка типа AND или OR часто применяются для склеивания заранее неизвестного числа операндов. При этом в памяти создается рекурсивная структура объектов – AND(x, AND(y, And(z, AND(...)))). Такую структуру неприятно просматривать в отладчике, и еще неприятней отлаживать ее пошагово. Поэтому иногда оправдано сделать, чтобы класс AND склеивал не 2, а произвольное число фильтров, а статический factory-метод Filters.and(first, second) (вы ведь не забыли абстрагировать в него конструктор, не так ли? ;) ) проверял, не является ли first или second уже и так AND'ом и, по возможности, склеивал их в один большой AND. Тогда рекурсивная структура превратится в итеративную и будет одно удовольствие смотреть ее в отладчике, сериализовать в XML и шастать отладчиком по циклу над слагаемыми.

Теперь тяжелая артиллерия, имеющая отношение собственно к ФП:

Прочитайте J vocabulary...

...и поймите, что делают примитивные функции и комбинаторы. Применять необязательно, достаточно просто понять, что они делают. http://www.jsoftware.com/help/dictionary/vocabul.htm. J – уникальный пример функционального языка без статической типизации и даже без замыканий. По сути, это означает, что большинство комбинаторов J можно реализовать и использовать и на Java, не скатываясь на анонимные классы. Мои любимые комбинаторы - "&." (f &. g = f-1 . g . f), "/." (x f/. y = вектор значений f(g) для каждой группы g вектора x по ключу y) и "/:" (x /: y - вектор x, отсортированный по вектору y).

Карринг по последнему аргументу

Функцию от нескольких аргументов можно интерпретировать как ответ на вопрос «Как Y зависит от x1, x2, …, xn?». Можно зафиксировать некоторые из xi и получить, например, ответ на вопрос «Как Y зависит от x2, x3, .., xn, если x1 = 5?» - получится функция от меньшего числа аргументов. Этот прием – фиксация некоторых аргументов – называется «карринг» и широко используется в функциональных языках.

Можно сказать, что функция от n аргументов – это функция от одного (первого) аргумента, возвращающая функцию от n-1 последующих. Обычно поддерживается фиксация нескольких первых аргументов функции – например, в случае функции plus(x, y) от двух числовых аргументов можно зафиксировать первый: plus(5) – и получится функция от одного аргумента y: plus(5)(y) = plus(5, y).

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

Java не поддерживает карринг напрямую, однако ничто не мешает воспользоваться самой идеей.

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

Плохо:

      public
      class CollectionUtils 
{
  publicstatic <T> List<T> filter(Filter<T> filter, List<T> ts) {...}
  publicstatic <K, V> List<V> map(Function<K, V> fun, List<K> ts) {...}
}

Хорошо:

      public
      class CollectionUtils 
{
  publicstatic <T> Function<List<T>, List<T>> filter(Filter<T> filter)
  {...}
  publicstatic <K, V> Function<List<K>, List<V>> map(Function<K, V> fun)
  {...}
}

Почему:

Потому что теперь комбинаторам высшего порядка есть что комбинировать – вместо «фильтров» и «отображений», которые можно лишь применить к каким-нибудь конкретным спискам, появились «фильтраторы» и «отображатели», из которых можно собирать более сложные функции:

      public
      class FP 
{
  publicstatic <A,B,C> Function<A,C> 
    chain(Function<A,B> first, Function<B,C> second) {...}
}

или так:

      public
      abstract
      class Function<K,V> {
  publicabstract V apply(K argument);
  
  public Function<K,U> then(Function<V,U> second) 
  {
    return FP.chain(this, second);
  }
}

А теперь можно писать что-нибудь такое.

Пусть тип Order имеет три свойства (Customer, Product, Time) – «кто, что и когда заказал».

      public
      abstract
      class Aggregate<K,V> extends Function<Collection<K>, V> {}

// select K,V from S group by Kpublicabstractclass Partitioned<S,K,V> 
  extends Function<Collection<S>, Map<K, V>> {}

Function<Order,Product> getProduct() {..}
Function<Product,Price> getPrice() {..}
Filter<Product> categoryEquals(String pat) {..}
Partitioned<Order,Month,T> byMonth(Aggregate<Order, T> inner) {..}

далее

      public Partitioned<Order,Month,Customer> mostGenerousCustomerByMonth() 
{
  Function<Order, Price> ORDER_PRICE = getProduct().then(getPrice());
  Aggregate<Order, Order> MOST_EXPENSIVE_ORDER =
    over(getProduct().then(categoryEquals("Cars")), 
     maximizeBy(ORDER_PRICE));
  return byMonth(MOST_EXPENSIVE_ORDER.then(Order.GET_CUSTOMER));
}

{
  ...
  Map<Month, Customer> goodGuysIn2007 = 
    filter(timeBetween(year(2007), year(2008))) // <---
    .then(mostGenerousCustomerByMonth())
    .apply(ourOrders);
  ...                                    
}

Код mostGenerousCustomerByMonth выглядит довольно плотно, если не сказать громоздко – однако он гораздо компактнее и читаемей, чем реализация без комбинаторов высшего порядка (попытка написать или представить таковую и сравнить ее с приведенной оставляется читателю в качестве упражнения).

Пары и бинарные отношения

Не так-то легко найти проект, в котором нету самописного класса Pair и не используются какие-нибудь List<Pair<Foo,Bar>>. Довольно часто возникают задачи вроде «собрать значения функции foo над левыми частями пар», или «собрать пары из значений foo на левой части и bar на правой», или «оставить только пары, у которых правая часть удовлетворяет предикату qux», и т.п. Было бы возмутительным не учредить для этого комбинаторы и не предоставить классу List<Pair<Foo,Bar>> возможность иметь их в качестве member methods:

      class BiRelation<L,R> 
{
  ...
  List<Pair<L,R>> allEntries() {..}
  static BiRelation<L,R> rel(List<Pair<L,R>> pairs) {..}
  BiRelation<R,L> flip() {..}
  BiRelation<L,R> filterL(Predicate<L> p) {..}
  BiRelation<R,L> filterR(Predicate<R> p) {return flip().filterL(p).flip();}
  BiRelation<L,List<R>> compressL() {..}
  BiRelation<List<L>,R> compressR() {return flip().compressL().flip();}
  BiRelation<P,Q> fmap(Function<L,P> f, Function<R,Q> g) 
  {
    return rel(map(pair(f,g)).apply(allEntries()));
  }
  List<T> map(Function<Pair<L,R>, T> fun) {...}
  ...
}

Здесь также иллюстрируется еще один полезный прием – "worker/wrapper" (обертывание во flip : flip()/.../flip())

Теперь можно, например, написать поиск в ширину:

      private
      static BiRelation<Node, Node> bfs(BiRelation<Node, Node> graph, Node root) 
{
  Set<Node> roots = singleton(root);
  Set<Node> reached = new LinkedHashSet<Node>();
  reached.add(root);
  List<Pair<Node,Node>> res = newList();
  while(true) 
  {
    BiRelation<Node,Node> nextLayer = 
      graph.filterL(memberOf(roots)).filterR(not(memberOf(reached))); // <---if(nextLayer.isEmpty())
      break;
    res.addAll(nextLayer.allEntries());
    reached.addAll(roots = nextLayer.rightSet());
  }
  return rel(res);
}

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

      public
      static Function<T, Pair<T,U>> attachR(Function<T,U> fun) {..}
publicstatic Function<Pair<T,U>, T> detachR() {..}
publicstatic Function<Pair<T,U>, U> detachL() {..}

publicstatic <U extends Comparable<? super U>> 
Function<List<T>,List<T>> schwartzSortBy(Function<T,U> fun) 
{
  returnmap(attachR(fun)).then(sortBy(second())).then(map(detachR())); 
}

Или вот:

BiRelation<Customer, Order> customer_order = ...
BiRelation<Product, Integer> product_buyersCount = 
  customer_order.fmap(ID, GET_PRODUCT).groupByR().fmap(SIZE, ID).flip();

И много чего другого можно написать. Вообще, flip() – очень общая и полезная функция; существует много симметричных структур – пары, бинарные отношения, обратимые функции, Map'ы... А тройные отношения можно поворачивать – TriRelation<A,B,C>.rotate213() и т.п. Но злоупотреблять этим не стоит.

Крупные комбинаторы

Есть такая архитектура процессоров – VLIW, Very Large Instruction Word. В одной инструкции помещается сразу несколько действий – например, перемножить и сложить с аккумулятором; вычислить синус и косинус; и т.п.Там это делается для повышения параллелизма на уровне команд и вообще повышения быстродействия.

В случае ФП на Java (и не только на Java) это можно делать для повышения читаемости и понижения количества скобок. Ну, и для быстродействия тоже – крупный комбинатор можно реализовать не через мелкие, а более эффективно (например, избавляясь от промежуточных результатов – этот прием называется deforestation, или fusion, т.е. «сплавка» нескольких комбинаторов в один).

Вместо:

Aggregate<Customer, Order> everyonesOrdersIn2008 = 
  map(GET_ORDERS)
  .then(concat())
  .then(filter(timeBetween(year(2007), year(2008))))

Делаем так:

      public
      static <K,V> Aggregate<K,V> concatMapOver(Function<K,V> fun, 
  Filter<K> filter) {...}

Aggregate<Customer, Order> everyonesOrdersIn2008 = 
  concatMapOver(GET_ORDERS, timeBetween(year(2007), year(2008)));

Заключение

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


Эта статья опубликована в журнале RSDN Magazine #3-2008. Информацию о журнале можно найти здесь