Сообщений 34 Оценка 421 [+1/-1] Оценить |
Есть задачи, которые удобнее всего решать в функциональном стиле. Среди этих задач есть такие, которые можно лаконично и читаемо решить и на 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(); |
Плохо:
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(); } }; |
Плохо:
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 и шастать отладчиком по циклу над слагаемыми.
Теперь тяжелая артиллерия, имеющая отношение собственно к ФП:
...и поймите, что делают примитивные функции и комбинаторы. Применять необязательно, достаточно просто понять, что они делают. 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, позволяет писать в значительно более читаемом стиле, нежели общепринятый, и позволяет даже использовать идеи функционального программирования. Автор надеется, что читатель найдет описанным приемам применение и испытает от их использования такую же радость. :)
Сообщений 34 Оценка 421 [+1/-1] Оценить |