Lex Kravetski (lex_kravetski) wrote,
Lex Kravetski
lex_kravetski

Category:
  • Music:

Ода к лаконичности

Меня, вот, часто спрашивают: «что это за замыкания такие, которые ты так часто поминаешь?». А я отвечу: замыкания, товарищи, эта такая штука, за которую любят функциональные языки. И вообще все языки, в которых есть замыкания хоть в какой-то форме. Да. Это такая штука. Клёвая штука. Сейчас поясню на примере.

Положим, есть у нас список, который надо отфильтровать — получить другой список, в котором все элементы первого, удовлетворяющие некому условию. Ну, для простоты положим, что в качестве первого списка выступает список со значениями типа int, а нам надо извлечь из него те значения, которые больше десяти, но меньше ста. Обычная ведь задача. Распространённая.

Мы берём С++ и пишем на нём такой вот код:

list<int>* fil(const list<int>* src) {

      list<int>* result = new list<int>();

      for (list<int>::const_iterator iter = src->begin(); iter != src->end(); ++iter) {

            int value = *iter;

            if (value > 10 && value < 100) { // Ключевой момент

                  result->push_back(value);

            }

      }

      return result;

}

Цель вроде бы достигнута. Список отфильтрован. Однако вот незадача — чуть дальше нам надо отфильтровать элементы не от десяти до ста, а от двадцати до тысячи. Не вопрос — в строке с пометкой «ключевой момент» мы введём переменные min и max, которые сделаем параметрами функции. Для универсальности. Теперь мы можем фильтровать элементы в любом диапазоне. Но ещё чуть дальше неожиданно понадобилось фильтровать от тридцати до бесконечности. Это мы, конечно, можем сымитировать при помощи задания верхнего предела как MAX_INT, но вот число тридцать должно в этот раз войти. Поэтому нижней границей мы делаем — двадцать девять.

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

Ну, как известно, С++ тем и мощен, что на нём миллионы программистов раз за разом пишут одну и ту же программу. Настоящих крутанов копипастой не напугать, однако на сотый раз вдруг выясняется, что концепция изменилась и вместо листов у нас теперь будут использоваться векторы. Поэтому сто раз размноженный алгоритм надо сто раз изменить — чтобы там теперь не лист, а вектор обрабатывался. Само собой — иным способом. Для вектора без заранее заданного размера push_back ведь сильно накладен.

В общем, так дело не пойдёт. Надо таки алгоритм иметь в единственном экземпляре и подставлять в него нужную проверку. Но как, джентльмены, как? Можно макрос фигнуть из двух частей. В первую войдёт всё до «if (» включительно. Во вторую от «) {» и далее (обе строки в «ключевом моменте»). Но макрос, он всегда чреват. И в будущем он нам аукнется.

По счастью, есть выход. Мы, значится, вводим эдакий предикат — интерфейс фильтра. А саму функцию фильтрации переписываем так, чтобы она в качестве условия этот самый предикат использовала.

class IFilter {

public:

      virtual bool accept(int value) = 0;

};


list<int>* filter (const list<int>* src, IFilter& f) {

      list<int>* result = new list<int>();

      for (list<int>::const_iterator iter = src->begin(); iter != src->end(); ++iter) {

            int value = *iter;

            if (f.accept(value)) { // Ключевой момент

                  result->push_back(value);

            }

      }

      return result;

}

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

int main(int argc, char **argv) {

      list<int>* src = new list<int>(); // Тут подразумевается получение списка извне

      int min = 10;    // Откуда-то раздобыли min

      int max = 100; // Где-то взяли max

 

      list<int>* filtered = filter(src, ???); // 1. Пишем фильтр с предикатом. 2. ??? 3. Profit

}

Но что же нам поставить на место вопросительных знаков? Чего-то ведь вроде «value > min && value < max». Однако такая штука в качестве предиката в С++ выступать не может, а IFilter ни про какие мин с максом не знает. Поэтому, чтобы воспользоваться ими в реализации, нам придётся передать их в её конструктор, где-то там запомнить и только потом использовать в методе accept.

class Filter : public IFilter {

public:

      int min, max;

      Filter(int from, int to) {

            min = from;

            max = to;

      }


      virtual bool accept(int value) {

            return value > min && value < max;

      }

};


int main(int argc, char **argv) {

      list<int>* src = new list<int>(); // Тут подразумевается получение списка извне

      int min = 10;  // Откуда-то раздобыли min

      int max = 100; // Где-то взяли max


      Filter fil(min, max);

      list<int>* filtered = filter(src, fil);

}

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

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

Теперь рассмотрим ровно такой же фокус, но на языке Java.

public class J {

      static interface IFilter {

            boolean accept( Integer value );

      }


      static List<Integer> filter( List<Integer> src, IFilter filter ) {

            List<Integer> result = new LinkedList<Integer>();

            for ( Integer value : src ) {

                  if ( filter.accept( value ) ) {

                        result.add( value );

                  }

            }

            return result;

      }


      public static void main( String[] args ) {

            // Тут подразумевается получение списка извне

            List<Integer> src = new LinkedList<Integer>();

            final int min = 10;    // Откуда-то раздобыли min

            final int max = 100; // Где-то взяли max

 

            List<Integer> result = filter( src, new IFilter() {

                  @Override

                  public boolean accept( Integer value ) {

                        return ( value > min ) && ( value < max );

                  }

            } );

      }

}

Перво-наперво мы видим, что сама реализация функции filter стала короче — за счёт синтаксического сахара. Оный сахар избавил нас от ненужных в данном случае итераторов. Что не может не радовать.

Но самое главное — использование. В явном виде мы не заводим новый класс, не передаём ему ничего в конструктор и вообще почти ничего не делаем. За счёт механизма внутренних классов и за счёт возможности создать анонимную реализацию, мы прямо в месте вызова определяем свой фильтр, метод accept которого «видит» определённые выше переменные min и max. А посему может их использовать. Всякая ненужная байда с конструкторами, запоминаниями параметров и описанием класса практически исчезла. Такая штука и называется «замыканием». То бишь, замыкание — это определение метода внутри другого метода. Внутренний метод при этом может использовать переменные метода, его окружающего. Именно за счёт этого количество строк существенно снижается. Более того, количество строк теперь не зависит от количества параметров. В случае с Джавой, впрочем, у нас не совсем замыкание — не сам метод внутри другого метода, а внутренний анонимный класс. Однако всё равно близко к тому, что надо. По крайней мере, код стал существенно короче, чем без всего этого.

Но, увы, даже так слишком длинно. Хотя на Джаве уже и не так лень пользоваться предикатами, как в С++, но можно было бы ведь ещё сильнее сократить код. Кабы нам дали способ определять функции прямо внутри других функций… И эти функции передавать как параметры в другие…

С++, к слову, позволяет делать указатели на функции. Правда, нас в данном случае это не спасёт — ведь функция accept, которую использует filter, содержит всего один параметр — value. А у нас есть ещё min и max, без которых наш фильтр смысла не имеет . И мы, увы, никак не сможем их передать в функцию accept, если сделаем ту не методом интерфейса IFilter, а просто внешней функцией, указатель на которую использует filter.

Ну сможем, конечно, — через глобальные переменные. Однако такой метод — жесть.

В общем, в Java замыкания условно есть и попользовавшись ими некоторое время некоторое время уже не представляешь, а как без них-то. При этом даже с ними постоянно присутствует чувство, что пишешь много лишнего — длинно оно. Длинно. И коряво выглядит. Безымянный класс какой-то, блин, метод внутри, ещё какая-то хрень — «оверрайд». Можно ведь ещё короче.

Например, так:

import scala.collection.jcl.Conversions._

import java.util.LinkedList

import java.util.List

 

object Scala {

      // Метод специально написан так, чтобы быть максимально похожим на оный в Java.

      // Именно поэтому здесь используются джавовские List и LinkedList

      def filter(src : List[Int], accept : (Int) => Boolean) : List[Int] = {

            val result = new LinkedList[Int]

            for (value <- src) {

                  if (accept(value)) {

                        result.add(value)

                  }

            }

            return result

      }

 

      def main(args: Array[String]) {

            val src = new LinkedList[Int] // Тут подразумевается получение списка извне

            val min = 10    // Откуда-то раздобыли min

            val max = 100 // Где-то взяли max

 

            val result = filter(src, (x) => x > min && x < max)

            println (result)

      }

}

Тут, как легко видеть, вообще ничего лишнего. Даже, к слову, метод filter мог бы быть короче — это я его специально чуть подраздул, дабы читателям было понятно. Но метод — детали: его один раз написал, а потом пользуешься и не вспоминаешь даже. Главное по-прежнему — использование. И тут оно на фоне не то что С++, а даже на фоне Java лаконично до предела. По сути фильтрация вообще вызвана одной строкой, в которой по сути один только критерий фильтрации и вписан. Без побочных классов, интерфейсов и всего остального.

Запись (x) => ... на самом деле означает определение функции. Той самой, про которую говорится в методе filter:

accept : (Int) => boolean

То есть, параметр — принимаемый метод называется accept. Дальше, через двоеточие его тип: функция, которая принимает единственный параметр типа Int, а возвращает Boolean. Когда мы пишем (x) => x > min && x < max, мы на самом деле говорим, что эта самая функция у нас имеет тело x > min && x < max, при этом параметр мы называем x — дабы был способ определить через него тело. По построению эта функция (безымянная и заданная просто выражением) действительно возвращает булевский тип. Эта безымянная функция попадает в качестве accept в filter, и в неё по очереди подставляются value, ну и всё через это дело фильтруется.

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

 

Короткое дополнение:

Может показаться, что тип всех переменных (на самом деле не переменных, а значений, типа, как final в Java) в коде — динамический. То есть, поскольку в явном виде их тип не указан, будто бы он определяется во время выполнения, что сразу навевает мрачные мысли о затаившихся багах, характерных для языков с динамическими типами.

Это не так: типы все статические. Однако они выводятся из контекста на этапе компиляции. Иными словами, никаких затаённых ошибок из-за этого возникнуть не может. Типизация строгая и беспрекословная.

В строке return result слово return — лишнее. Я написал его, чтобы незнакомым с языком было проще понять, о чём речь.

Точки с запятой действительно не обязательны.

Программа кончается строкой println(result) поскольку компилятор не позволяет закончить метод просто определением переменной (значения).

Tags: программирование
Subscribe
  • Post a new comment

    Error

    Anonymous comments are disabled in this journal

    default userpic

    Your reply will be screened

    Your IP address will be recorded 

  • 95 comments
Previous
← Ctrl ← Alt
Next
Ctrl → Alt →
Previous
← Ctrl ← Alt
Next
Ctrl → Alt →