Предыдущий пост Поделиться Следующий пост
Про итераторы
lex_kravetski
С тех пор, когда вообще додумались до того, что последовательность действий можно оформить в алгоритм, который к тому же выполнять не вручную, а отдать на выполнение компьютеру, утекло весьма много времени. И придумали в области написания программ преизрядно всякого. В том смысле, что не смотря на активный массовый быдлокодинг, отдельным гражданам всё-таки мечталось завязать с написанием одного и того же кода по сто раз на дню, от чего те стремились с нижних, близких железу уровней к уровням высоким, где накал абстракций особенно силён. И хотя такие абстракции зачастую сходу понять сложнее, чем if-else какой, в результате, после тренировки с ними всё равно как-то проще. И писать и думать. А ещё не только проще, но и меньше вдобавок.

Я, правда, в терминах машины Тьюринга никогда размышлять не пытался, да и ассемблер не жалую, поэтому в интуитивности машинного кода сильно неуверен, однако if-else в бейсиках, сях, да и вообще везде, есть мнение, однозначно интуитивней наследования объектов и всего такого прочего. С другой стороны, я даже представить себе не могу, каково это: написать, скажем, Ворд в терминах таких интуитивно понятных ифов и элсов. Вот в объектных терминах — да, представляю.

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

Так вот, расскажу об одной такой абстракции. Самой, как мне кажется, выдающейся в плане своей универсальности и воспринятости обществом: контейнерах. Даже нет, что там контейнеры, об итераторах. Ими, как и контейнерами, пользуются почти все, но мало кто отдаёт себе отчёт, насколько широко ими можно воспрользоваться.

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

Сейчас какой язык ни возьми, везде в той или иной мере будут представлены списки. Есть даже язык, которые чуть не только за ради списков был разработан — Lisp. Он сам себе список и списком при этом погоняет. Читать, правда, очень неудобно, поскольку запись инопланетная, но, тем не менее, изрядным прорывом он был. А так, даже в Бейсиках этих ваших уже были массивы — частный случай коллекции.

А если есть коллекция, то есть и действия над ней. Точнее, не столько над ней, сколько над её элементами и, иногда, над их взаимным расположением (что, впрочем, обычно сводится к сортировке). Раз так, нужен способ каким-то образом все элементы «обходить». И способ сей, по счастью, сейчас общеизвестен: итератор.

Итератор — суть абстракция, обладающая тремя свойствами:


  1. Его можно связать с интересующим нас множеством
  2. Ему можно говорить «дай следующий элемент»
  3. И его можно спрашивать «есть ли следующий элемент?», чем, вкупе с п. 2, не исключено, добиться посещения всех элементов этого множества


Как это обычно выглядит в Java


	void printAll() {
		List<Integer> list = giveMeList();
		Iterator<Integer> iterator = list.iterator();
		while ( iterator.hasNext() ) {
			Integer i = iterator.next();
			System.out.println( i );
		}
	}


Или, если короче

	void printAll() {
		List<Integer> list = giveMeList();
		for ( Integer i : list ) {
			System.out.println( i );
		}
	}


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

Iterable — это тот, кто умеет возвращать Iterator.

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

Что же это за проблемы? Во-первых, суровая ассиметрия. Массив, хоть и может использоваться в короткой записи for, тем не менее, не является Iterable и итератор возвращать не умеет. В сочетании с особым синтаксисом обращения к своим элементам — через квадратные скобки — java-массив усложняет вдобавок и обращение с коллекциями тоже. Кроме того, короткий вариант for не работает с итератором, что зачастую портит всю малину. Итератор при этом крайне тяжело подменяется коллекцией и наоборот — их типы почему-то несводимы друг другу, хотя по смыслу в ряде контекстов разницы особой вроде бы и нет. В общем, ассиметрия сильно затрудняет обобщение алгоритмов, что снижает удобство использования собственных итераторов.

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

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

Однако вторая проблема ещё серьёзнее: итератор стандартной библиотеки Java не содержит никаких полезных функций, кроме непосредственно next и hasNext. Из-за этого вся глубина и мощь итераторов рушится прямо на глазах.

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

Кстати, можно и совсем без циклов: любой цикл сводим к набору рекурсивных функций.
	void printAllReq( Iterator<Integer> iter ) {
		if( iter.hasNext() ) {
			Integer i = iter.next();
			System.out.println( i );
			printAllReq( iter );
		}
	}
 
	void printAll() {
		List<Integer> list = giveMeList();
		printAllReq( list.iterator() );
	}

Не делайте этого дома на Java — такое может серьёзно ударить по вашему стеку. И заодно по производительности программы.

Для понимания сути подхода, лучше всего представлять любую итерируемую коллекцию, как частный случай последовательности элементов (даже если коллекция по смыслу не совсем линейная, как, например, Set). Стандартные же операции будут определены над последовательностью и её итератором.

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

map(f) — произвести из данной последовательности новую того же класса, выполняя над каждым элементом исходной переданную в качестве параметра операцию f
zip(seq) — произвести из данной последовательности и ещё одной (seq) последовательность пар элементов с совпадающим порядковым номером
foreach(f) — выполнить переданную в параметр операцию для каждого элемента последовательности (тот самый «for с двоеточием» по сути)
dropWhile(p) — произвести новую последовательность, отбрасывая от старой элементы до тех пор, пока выполняется условие p
filter(p) — произвести новую последовательность, составив её из тех элементов исходной, для которых выполняется условие p

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

Для Java есть библиотека Functional Java, где всё это дело реализовано. Однако для примеров я буду использовать гораздо более лаконичную Scala.

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


  1. Из исходной последовательности следует взять только неотрицательные числа (filter)
  2. Из последовательности п.1 следует сделать последовательность корней чисел (map)
  3. Каждый элемент последовательности п.2 следует вывести на экран (foreach)



Так и запишем:


	def printAllRoots(seq: Iterable[Double]) = seq.filter(x => x >= 0).map(x => sqrt(x)).foreach(println) 
	
	def main(args: Array[String]) {
		val l = List(-2., 5., 10.)
		printAllRoots(l)
	}


В общем-то, прочитать и понять может даже незнакомый с языком после пары пояснений. Во-первых, запись x => x >= 0 — это определение анонимной функции с одной переменной (которую мы обозвали x), возвращающей результат сравнения переданного параметра с нулём. Запись можно было бы сократить, отказавшись от имени переменной и использовав вместо неё подстановочный символ: _ >= 0. С вычислением корня всё аналогично.

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

Наконец, запись можно сделать несколько более удобочитаемой, заменив точки пробелами.

	def printAllRoots(seq: Iterable[Double]) = seq filter(_ >= 0) map(sqrt) foreach(println)


Собственно, всё решение уложилось в одну строку. И от этого даже не стало особо хуже читаемым. Даже скорее наоборот — это циклы с вложенными условиями наверно читались бы хуже.

Однако к чему тут итераторы? Чтобы это понять, надо сначала осмыслить реальное поведение только что написанного алгоритма. Итак, на вход поступает некий итерируемый объект — последовательность. Функция filter создаёт новый объект, в котором из старого оставлены только прошедшие фильтр элементы. Потом ещё один новый объект создаётся при помощи map. Наконец, для элементов этого последнего объекта вызывается println. То есть, в процессе работы были созданы две новых последовательности, обе из которых были сразу же забыты. Однако память под это дело выделялась, создавались какие-то внутренние массивы или цепочки объектов-элементов или что-то вроде такого. Одно дело, мы бы хотели где-то сохранить на память промежуточные результаты, но это-то нам не нужно. У нас просто получилась функция, которая не особо эффективно работает, потребляя память и процессорное время на совершенно лишние действия.

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

	def printAllRoots(seq: Iterable[Double]) = seq.iterator filter(_ >= 0) map(sqrt) foreach(println)


Единственное, что поменялось, — это вместо непосредственно seq стал использоваться seq.iterator. Теперь новые коллекции уже не создаются, а создаются новые итераторы — функциями map и filter. Каждый из новых итераторов как бы «накапливает» операции, запоминая внутри себя исходный итератор и действие, которое над ним следует совершить. Так, получившийся в результате выполнения filter и map итератор будет работать следующим образом: при вызове next, он будет вызывать next у исходного итератора, проверять его на соответствие фильтру (и если соответствия нет, снова вызвать next у исходного, пока оно не будет достигнуто), после чего производить новый элемент, согласно написанному в map. То есть, снаружи всё будет выглядеть словно у нас есть некая «виртуальная коллекция», отличающаяся от исходной только тем, что она «исчезнет», как только итератор дойдёт до последнего элемента. Хотя на самом деле, конечно, никакой иной коллекции кроме исходной нет.

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

Ну и, как уже говорилось, проще спроектировать и записать решение — не вдаваясь в детали нагромождений циклов и условий друг вокруг друга.

Рассмотрим для примера ещё одну задачу. Даны два списка, в каждом из которых все элементы отличны от null, надо найти точку, с которой начинается различие этих двух списков.

Начнём рассуждать о решении «в лоб». Итак, нам надо завести два итератора и итерировать их одновременно, проверяя на каждом этапе, не различны ли элементы им соответствующие. Когда мы найдём отличие, итераторы будут указывать на следующий элемент, после того, с которого различие начинается. Затем нам надо что-то вернуть в качестве результата, но что? Сами элементы на практике наверно будут не особо полезны. Индекс, с которого различие начинается, заставит клиента функции ещё раз пройти до данного индекса, что не очень эффективно, если он передал связные списки в качестве параметров. То есть, надо возвращать сами итераторы. Которые, как мы помним, указывают на следующий элемент различия, поэтому их следует «откатить» на один шаг назад. Ещё необходимо отдельно предусмотреть случай, когда один из списков уже закончился, а различие всё ещё не найдено. В общем, сходу такую функцию написать без ошибок — та ещё задача. Лично мне о её реализации даже думать лень.

Зато обдумать «функциональную» реализацию вполне даже можно.


  1. Из двух списков надо сделать список пар соответствующих элементов. Полный список пар. То есть, если один из списков уже закончился, то парный элемент должен быть неким маркером того, что во втором списке элементов уже нет. Нам подойдёт в качестве маркера null, поскольку мы знаем, что в списках таких элементов нет.
  2. Следует отбрасывать из списка пар элементы до тех пор, пока элементы пары равны.
  3. Это и есть результат — список пар, начинающийся с первого различия.
  4. Само собой, надо использовать не сами списки, а итераторы, поскольку промежуточные значения нам сохранять не надо. Но логику рассуждений это не меняет.


Вуаля. Простое и практически очевидное решение. Которое можно очень быстро написать с малой вероятностью ошибки.

	def diff(seq1: Iterable[Int], seq2: Iterable[Int]) =
		seq1.iterator zipAll (seq2.iterator, null, null) dropWhile {case (x1, x2) => x1 == x2}


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

case (x1, x2) используется для локальной «распаковки» пары в переменные x1 и x2.

При этом, что характерно, непосредственно вычисления (производство пар и их отбрасывание вплоть до точки различия) начнутся только в тот момент, когда клиент захочет с итератором-результатом что-то сделать. Например, вызвать для него next. До этого же итератор будет храниться как бы «на взводе». То есть, в состоянии, когда по исходному списку ещё не итерировались, ни одной пары не произвели и ничего ещё не отбросили, но уже настроили и запомнили весь алгоритм последующих действий.

Называется такое — «ленивые вычисления». Очень полезная абстракция.

Желающие могут попробовать самостоятельно написать решение «в лоб» и сравнить его трудоёмкость с приведённым мной решением. И, само собой, длину кода сравнить.

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

Примером такого контейнера может быть числовой диапазон. Что-то типа Range(start, end). Диапазон тоже Iterable. Из него можно извлечь итератор, а проитерироваться по всем элементам.

Или итерироваться до бесконечности, если сам диапазон — бесконечный.

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

class Primes(seq: Iterable[Int]) extends Iterable[Int]{
	def this(to: Int) =	this(Range(1, to))
 
	def iterator = seq.iterator filter isPrime
 
	def primesRange(x: Int) = 2 to x/2
 
	private def isPrime(x: Int) = primesRange(x) forall (x % _ != 0)
}
 
object Primes {
 	def main(args: Array[String]) {
		val primes = new Primes(200)
		primes.foreach(println)
	}
}


В данной реализации Primes — обёртка над любой последовательностью, отфильтровывающая из неё все не простые числа. Снаружи ведёт себя такой объект ровно как обычный список. Для часто встречающейся задачи — получения списка простых чисел от 1 до N — в объекте определён дополнительный конструктор this(to: Int), использующий в качестве базовой последовательности виртуальную — тот самый Range.

Какая бы ни была последовательность, Primes использует её родной итератор с приделанным к нему фильтром. Что позволяет добиться универсальности крайне малыми силами — чисто благодаря правильно выбранному подходу. Проверка, является ли число простым, обратно же, реализована без циклов, но целиком через операции с последовательностями: для числа x берётся последовательность (в данном случае — виртуальная, диапазон от 2 до x/2), после чего проверяется, действительно ли на каждое число из последовательности x делится с остатком.

Вынесение проверочного диапазона primesRange в отдельную функцию (на фоне вписывания его внутрь вложенных циклов в виде цепочек условий) упрощает нам дальнейшие оптимизации. Например, осознав, что скорость работы isPrime можно ощутимо повысить, возвращая из PrimesRange пустую последовательность для x≤3, а для x>3 проверять делимость x на 2 и на каждое нечётное число вплоть до x/2, мы оптимизируем алгоритм путём модификации одного только primesRange. При этом и клиентский код и даже остальной код класса Primes останется неизменным.

	def primesRange(x: Int) = if (x <= 3) List() else (2 to 2).iterator ++ (3 to x/2 by 2).iterator


Тут, как можно видеть, используется равнозначность списка и итератора, позволяющая вернуть один взамен другого. Кроме того, из двух итераторов при помощи ++ делается составной итератор. Таким образом мы малыми силами создаём итератор, первым делом возвращающий значение 2, а потом — каждое второе значение от 3-х до x/2.

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

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

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

Подход сей, в общем, весьма рекомендую.

  • 1
Всё прекрасно, но итераторы на простых типах контейнеров(массив) будут работать медленнее в РАЗЫ. Если элементов в контейнере немного(условно, до 40 000) - плевать. Если больше - то придется использовать старые приемы.

Также вижу подводный камень при смешивании нескольких итераторов в одной конструкции. Предполагаемый компилятор может принять решение о копировании элементов вместо использования ссылок на них. Потому что в случае неопределенности - это единственный способ обеспечить корректное вычисление. А это поднимет старые проблемы. В C++ они известны как конструкторы копирования/присваивания, модификаторы const, mutable и прочая мудянка.

Re: Ответ на вашу запись...

> Всё прекрасно, но итераторы на простых типах контейнеров(массив) будут работать медленнее в РАЗЫ. Если элементов в контейнере немного(условно, до 40 000) - плевать. Если больше - то придется использовать старые приемы.

Эдак объекты лучше вообще не использовать — на С-шных массивах быстрее работать будет.

> Также вижу подводный камень при смешивании нескольких итераторов в одной конструкции. Предполагаемый компилятор может принять решение о копировании элементов вместо использования ссылок на них.

В Java и Scala есть только ссылки. Там нет переменных-значений.

Cкажите , а что если всё писать в одну строку , то читабельнее получается?

Re: Ответ на вашу запись...

От длинны строки зависит.

нетривиальные решения тривиальных задач. забавно.
вообще какова область применения scala? а то гугл на первых двух-трех страницах мне ни чего внятного не предложил...

> вообще какова область применения scala?

Та же что и у Java. Только сильно короче получается и повторно использовать проще.

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

Тупо начал вспоминать когда мы успели проскочить два элемента после обнаружения различия. Потом причитал следующую фразу, и понял, что ты просто не совсем удачно выразился. Лучше было-бы примерно так:

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

А вообще, я обычно предполагаю в итераторе метод current() - вернуть текущий элемент.
Статься понравилась.

Re: Ответ на вашу запись...

> А вообще, я обычно предполагаю в итераторе метод current() - вернуть текущий элемент.

Так вот в явовском итераторе его нет.

По-моему для упртребления слова ассиметрия не хватает cимметрии. Мысль: "массивы выбиваются из интрфейса коллекций, эта непоследовательность/несостоятельность/неконсистентность/несовместимость затрудняет ..." подразумевает что все коллекции одинаковы, а плохая ассиметрия всё прортит. Но ведь симметрия не есть однородность, симметрия требует противоположности в каком-то свойстве. Между какими двумя объектами подразумевается симметрия?

Re: Ответ на вашу запись...

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

Отлично! Спасибо за статью. Если пожелания уместны, было бы интересно почитать про монады и продолжения.

Re: Ответ на вашу запись...

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

(Удалённый комментарий)

Re: Ответ на вашу запись...

Сходу не знаю, как поправить.

(Удалённый комментарий)
(Удалённый комментарий)
Использовать это легко, но чтобы писать такие абстракции надо иметь достаточно подготовленную команду. Это основная проблема.

Если бы не быдлокодеры, то наиболее распространённый язык был бы Lisp.

Re: Ответ на вашу запись...

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

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

  • 1
?

Log in

No account? Create an account