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

С постепенным усложнением класса, его описание начинает увеличиваться в объёме. Кроме этого, могут появиться несколько вариантов выполнения классом тех или иных действий. Начиная с этого момента, есть смысл задуматься об отдельных ответах на вопросы «Что делает объект» и «Как он это делает». В Котлине, ответ на первый вопрос дают интерфейсы (interface), на второй — классы (class).

Интерфейсы

Интерфейс определяет, что должен уметь делать объект. С точки зрения Котлина, это набор свойств (с их типами) и функций (с их параметрами и результатом), которые должны у этого объекта иметься.

Рассмотрим в качестве примера матрицу, то есть прямоугольную таблицу, имеющую M рядов и N колонок. В совокупности, матрица имеет M * N ячеек, в каждой из которых хранится элемент матрицы определённого типа. Будет считать, что типы всех элементов матрицы совпадают. В этом случае, определение интерфейса матрицы на Котлине может выглядеть так:

Здесь Cell — элементарный класс с данными для хранения координат (ряд, колонка) определённой ячейки матрицы. Заголовок interface Matrix<E> определяет интерфейс с именем Matrix, использующий настраиваемый тип E. Никаких ограничений на этот тип не задано, поэтому он может быть произвольным. В данном случае предполагается, что все элементы матрицы имеют тип E.

Свойства height и width определяют высоту (число рядов) и ширину (число колонок) матрицы. Это целые числа, но значения их не заданы, поскольку интерфейс определяет лишь, что есть у матрицы. Две операторных функции getпредназначены для определения содержимого определённой ячейки матрицы (для удобства, одна из них работает с двумя целочисленными параметрами, другая — с одним параметром-ячейкой). Результат обеих функций имеет тип E(поскольку элементы имеют этот тип), но как он определяется — в интерфейсе опять-таки неизвестно.

Наконец, операторные функции set предназначены для замены содержимого определённой ячейки матрицы. Их последний параметр содержит элемент, который нужно записать в заданную ячейку. Результат у данных функций отсутствует, но они меняют внутреннее состояние матрицы, то есть имеют побочный эффект. Вызывать set можно как непосредственно, так и с помощью индексации: matrix[cell] = value эквивалентно matrix.set(cell, value). Как get, так и set предполагаеют, что номер ряда лежит в диапазоне 0..height - 1, а номер колонки в диапазоне 0..width - 1.

Интерфейсы определяют новый тип или множество типов — в данном случае Matrix<E> — но не имеют конструкторов. Имея только интерфейс, создать объект данного типа нельзя. Тем не менее, имея только интерфейс, можно определять различные операции над уже имеющимся объектом. Например, следующая функция меняет знак всех элементов целочисленной матрицы на обратный:

Заметьте, что здесь мы ничего не знаем о внутреннем устройстве матрицы и никак не используем информацию о нём. Сам код достаточно тривиален и не требует особых объяснений. Оператор matrix[row, column] = -matrix[row, columnиспользует сразу обе операторные функции getset и эквивалентен следующему: matrix.set(row, column, -matrix.get(row, column)).

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

Здесь в парах get / set первая из функций объявлена без реализации, а вторая использует первую.

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

Неискушённого читателя это может удивить, но типы List<T> и MutableList<T>, так же, как и Set<T>MutableSet<T>Collection<T>MutableCollection<T> — все являются интерфейсами. В описании списков ничего не говорится о том, как именно эти списки работают. Например, существует две широко известные реализации интерфейса MutableList<T> — а именно, ArrayList<T> (список, реализованный на основе массива, в котором все элементы хранятся единым куском), и LinkedList<T> (так называемый связанный список, в котором каждый элемент содержит ссылку на следующий и на предыдущий, при этом хранятся все элементы отдельно). Но, имея MutableList<T>, мы не знаем, с какой конкретно из этих двух реализаций мы имеем дело, а в случае List<T>, могут также присутствовать и дополнительные реализации для неизменяемого или пустого списка.

Функции-создатели

Функции-создатели в некотором смысле являются заменой конструкторам. Они предназначены для создания объекта, реализующего тот или иной интерфейс; при этом, естественно, для их написания должна существовать реализация данного интерфейса и функция-создатель обычно вызывает внутри себя тот или иной конструктор, иногда делая выбор из нескольких вариантов. Известными примерами функций-создателей являются listOf(…​)mutableListOf(…​)setOf(…​)mutableSetOf(…​). Для матрицы, подобная функция может быть определена так:

Здесь fun <E> говорит о том, что функция использует настраиваемый тип E — тип элементов матрицы. Первый и второй параметр задают высоту и ширину матрицы, а третий — значение элемента, который при создании матрицы будет записан во все ячейки. Результатом функции должна стать вновь созданная матрица, но, поскольку реализации интерфейса Matrix<E> ещё нет, вместо вызова конструктора в теле фигурирует TODO() — специальная функция, бросающая исключение UnsupportedOperationException.

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

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

При попытке протестировать эту функцию мы получим исключение UnsupportedOperationException при создании матрицы — до тех пор, пока не сделаем её реализацию и не используем её в функции-создателе.

Скелет реализации интерфейса

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

Заголовок class MatrixImpl<E> : Matrix<E> говорит о том, что мы определяем класс MatrixImpl<E>, который является реализацией интерфейса Matrix<E> и использует настраиваемый тип E. Далее перечисляются все свойства и функции, имеющиеся в Matrix<E>; перед каждым из них добавляется модификатор override — он сигнализирует об определении свойства / функции, уже имеющихся в интерфейсе. Класс, в отличие от интерфейса, должен содержать реальные тела функций и реальные значения свойств — но в скелете они заменяются на TODO(). В конце класса перечисляются две упоминавшиеся ранее функции equals и toString — первая для сравнения (матриц) на равенство, а вторая для представления матрицы в виде строки.

Здесь въедливый читатель, заметив перед equals и toString модификатор override, может задаться вопросом — а две этих функции тоже определены в каком-нибудь интерфейсе? Это предположение не вполне верно. Определения двух этих функций имеются в специальном классе Any, определяющем тип «любой». Напомним, что в Котлине любой тип является разновидностью типа Any?, то есть множество значений Any? — вообще все значения, которые могут существовать в программе на Котлине. Any без вопроса имеет то же множество значений, за вычетом специального null. Это, в частности, значит, что сравнение на равенство и представление в виде строки в Котлине можно выполнить для чего угодно.

Варианты реализации интерфейса

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

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

Обратите внимание, что определения свойств высоты и ширины исчезли из тела класса и переехали в конструктор — при этом сохранив необходимый модификатор override.

Что касается набора элементов, то здесь актуальна вторая часть вопроса — как представить этот набор? Для этого нужен некоторый контейнер, ссылка на который хранилась бы в ещё одном свойстве матрицы. Лучше, чтобы это свойство было закрытым, чтобы возможные действия с матрицей ограничивались лишь свойствами и функциями из интерфейса Matrix<E>. Существует несколько вариантов такого контейнера. Рассмотрим некоторые из них.

Сквозной список

В матрице высотой height и шириной width всего имеется height * width элементов. Они все могут быть сохранены внутри мутирующего списка (мутирующего — потому что в матрицу включены возможности изменения элементов). Определение этого списка могло бы выглядеть так:

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

Её третьим параметром был элемент для заполнения матрицы, и его нам необходимо передать в конструктор:

Здесь init { …​ } — это так называемый анонимный инициализатор. Операторы, указанные в этом блоке, выполняются сразу же после создания класса и записи начальной информации в его свойства.

Таким образом, наш список будет заполнен height * width элементами e сразу после создания матрицы. В дальнейшем, в функциях get и set мы должны будем прочесть или перезаписать элемент списка list по определённому индексу, зависящему от row и column.

Список списков

Элементы матрицы высотой height и шириной width можно также представить как список размера height, состоящий, в свою очередь, из списков размера width (состоящих из отдельных элементов типа E). Тип подобного контейнера определяется как List<List<E>>.

Список заранее неизвестного размера может быть создан с помощью функции List(size: Int, init: (Int) → E). Её первый параметр — требуемый размер списка, а второй — функция, определяющая, какой элемент хранится по какому индексу. Например, следующий вызов конструктора создаст список размера width из одинаковых элементов e:

При создании списка списков следует иметь в виду, что элементами внешнего списка в свою очередь являются списки, и создавать их тоже надо с помощью функции List.

Примерно аналогичным образом можно представить элементы матрицы в виде одного сквозного массива, или же в виде массива массивов. Возможны и другие варианты. В частности:

Ассоциативный массив

Элементы матрицы также можно представить в качестве ассоциативного массива, отображающего Cell в EMutableMap<Cell, E>. В такой карте каждой ячейке матрицы будет соответствовать свой элемент, причём ячейка будет служить индексом. Например:

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

При использовании ассоциативного массива следует помнить, что выражение map[cell], обеспечивающее чтение элемента из определённой ячейки, имеет тип E?, а не E. Операторная функция get, однако, имеет результат типа E(отличающийся тем, что null не входит в его множество значений). Поэтому в функции get следует явно написать, как нужно обрабатывать полученный null. При наивном коде вроде этого:

мы получим ошибку компиляции в функции get.

Реализация equals / hashCode

Часть вопросов, связанных с реализацией equals, мы рассмотрели в 8-м разделе. Когда необходимо написать эту функцию, следует ответить себе на вопрос: а когда, собственно, матрицы считаются равными? В данном случае очевидный ответ таков: когда равны их высоты и ширины, а также равны все соответствующие друг другу элементы. Также следует помнить, что тип параметра equals — Any?, а значит, перед сравнением следует проверить, что этот параметр принадлежит к типу Matrix<E> или MatrixImpl<E> (проще второе; в первом случае мы оставляем за собой возможность признать равными две разных реализации одной и той же матрицы — скажем, сравнение списков работает именно так).

Шаблон для реализации equals выглядит примерно так:

Обратите внимание на то, как проверяется тип otheris MatrixImpl<*>, то есть E заменяется на *. Такая запись означает «MatrixImpl с элементами произвольного типа» и связана с особенностями реализации настраиваемых типов в JVM. Во время выполнения программы можно определить принадлежность к основному типу MatrixImpl, но нельзясделать то же самое для какого-либо его конкретного варианта, например MatrixImpl<Int>. Попытка написать other is MatrixImpl<E> приведёт к предупреждению компиляции Unchecked Cast.

При реализации equals в своём классе следует помнить о пяти различных свойствах, которым эта реализация должна удовлетворять:

  1. Что угодно равно самому себе
  2. Если A равно B, то B равно A
  3. Если A равно B и B равно C, то A равно С.
  4. Никакое значение из типа Any не может быть равно null.
  5. Результат сравнения A и B не должен меняться при повторном вызове equals, ЕСЛИ внутреннее состояние A и B не изменилось между вызовами.

Реализовав equals в MatrixImpl, посмотрите на определение класса внимательнее. Вы заметите, что название класса подсвечено, и имеется предупреждение о реализации функции equals при отсутствующей реализации функции hashCode. Эту реализацию можно сгенерировать автоматически, если зайти в меню действий IDEA (Alt+Enter) и выбрав пункт Generate hashCode(). В результате мы получим что-то вроде:

Что же такое этот хеш-код? Это целое число, «привязанное» к любому значению типа Any и имеющее следующие свойства:

  1. Если A равно B, то хеш-код A ВСЕГДА равен хеш-коду B.
  2. Если A не равно B, то, КАК ПРАВИЛО (но не всегда!), хеш-код A не равен хеш-коду B.

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

Реализация toString

Функция toString() используется для формирования строкового представления любого значения типа Any?. Она неявно используется в строковых шаблонах вроде "$someVal" — при подстановке значения в строку оно заменяется своим строковым представлением. Она же неявно используется в отладчике при отображении значений ключевых переменных. Она же используется при провале тестов для отображения несовпадающих значений — ожидаемого и реального.

Таким образом, хотя toString() не очень часто используется напрямую, она очень полезна для вспомогательных целей. По этой причине, программистам на Котлине (и на Java тоже) рекомендуется переопределять эту функцию так, чтобы по строковому представлению можно было понять реальное содержимое объекта. Скажем, список традиционно представляется в виде строки как [elem1, elem2, elem3]. Из тех же соображений, матрицу можно было бы представить как [[elem11, elem12], [elem21, elem22], [elem31, elem32]], где elemIJ — элемент из I-го ряда и J-й колонки.

Функция toString() не имеет параметров, а её результат имеет тип String. Шаблон для матрицы может выглядеть так:

Здесь StringBuilder — специальный тип, используемый для построения внутри себя строки. Его использование здесь эффективнее, чем определение var str: String и построение строки в нём, поскольку такой метод потребует многократного создания новых строк. Внутри себя StringBuilder содержит постепенно расширяющуюся строку, изначально пустую. Функция sb.append дописывает к этой строке новую, а "$sb" в конце достаёт из построителя накопленную строку.

Упражнения

Откройте файл srс/lesson9/task1/Matrix.kt в проекте KotlinAsFirst.

Как скачать и подключить проект KotlinAsFirst смотрите во Введении.

Он содержит определение интерфейса Matrix<E>, функции-создателя createMatrix и реализации MatrixImpl<E>. Выберите один из рассмотренных выше вариантов реализаций матрицы (или придумайте свой) и напишите определения ВСЕХ функций в классе MatrixImpl, после чего напишите определение функции-создателя. Протестируйте свою реализацию, используя тесты из test/lesson9/task1/Tests.kt.

Откройте теперь файл srс/lesson9/task2/Matrices.kt. Файл содержит задачи на различные операции с матрицами, все они используют готовый интерфейс Matrix<E>. С использованием данного интерфейса и функции-создателя, решите одну или несколько задач из этого файла. Протестируйте свою реализацию, используя тесты из test/lesson9/task2/Tests.kt. Многие задачи из этого файла сложны, особенно это касается двух последних задач про «Игру в 15″ — вторую из них, пожалуй, следует считать самой сложной задачей в этом курсе.

Поздравляем, вы решили и все дополнительные задачи нашего курса по Котлину. В качестве свободного чтения вы можете прочитать раздел 10 (он не содержит задач).

Добавить комментарий