В этом и следующем разделе мы поговорим о том, как разрабатываются сложные классы и зачем это вообще нужно.
Из раздела 8 мы уже знаем, что классы помогают нам структурировать данные и определять над ними различные законченные операции. Около 30 лет назад программисты решили для себя, что при разработке программ сложных человеку проще отталкиваться от понятий, участвующих в задаче, чем от действий, которые надо выполнить для её решения. Так зародилась парадигма объектно-ориентированного программирования (ООП).
Если мы используем ООП, то, решая задачу, мы думаем прежде всего о том, какие в ней фигурируют понятия (существительные), и как эти понятия наиболее адекватно описать на языке программирования. Как правило, одному понятию исходной задачи ставится в соответствие один класс. Какие именно понятия исходной задачи являются настолько важными, чтобы заслужить наличие отдельного класса в программе, определяет программист, исходя из своего опыта.
К примеру, представим себе, что мы пишем арифметический тренажёр для учеников средней школы, и этот тренажёр должен научить их делать элементарные операции с рациональными числами вида P/Q
. В таком проекте важными понятиями будут являться "рациональное число" и "тренажёр".
Следующим шагом мы должны определить, что входит в каждый из этих классов. Класс состоит из данных, которые хранятся в каждом его объекте, и функций, работающих с этими данными, причём, как правило, данные становятся закрытыми private
, а функции остаются открытыми. Подобный принцип называется инкапсуляцией (см. также раздел 8.5). К примеру, для рационального числа данными (свойствами) являются числитель и знаменатель, а функциями — сложение, вычитание, умножение и деление, сравнение на равенство, больше, меньше, преобразование к целому и вещественному типу. Для тренажёра данными могут являться два рациональных числа, над которыми ученик должен выполнить операцию, и ожидаемый результат, а функциями — случайная генерация этих чисел, проверка правильности результата ученика, подсчёт количества правильных ответов и так далее.
Рассмотрим подробнее проектирование класса "рациональное число". Первая идея, которая здесь приходит в голову — описать данные класса следующим образом:
// numerator = числитель, denominator = знаменатель
class Rational(val numerator: Int, val denominator: Int)
Попробуйте подумать сами, какая проблема может быть связана с этим представлением, прежде чем читать дальше.
Проблема становится понятна, как только мы попытаемся сопоставить числа Rational(1, 2)
и Rational(3, 6)
и, к примеру, сравнить их на равенство. Числа 1/2
и 3/6
очевидно равны друг другу, но очевидно это только человеку. Для того, чтобы в этом мог убедиться компьютер, необходимо сократить дробь 3/6
, разделив числитель и знаменатель на 3 и превратив её в 1/2
. Это означает, что внутри функции equals
нам придётся выполнять такую операцию сокращения и, что ещё хуже, учитывать её при расчёте хеш-кода (про него см. подробнее раздел 9).
Гораздо проще выполнить сокращение сразу же, при создании рационального числа, а в функции equals
осуществлять только сравнение числителя и знаменателя. Например:
class Rational(numerator: Int, denominator: Int) {
val numerator: Int
val denominator: Int
private tailrec fun gcd(a: Int, b: Int): Int =
when {
a == b || b == 0 -> a
a == 0 -> b
a > b -> gcd(a % b, b)
else -> gcd(a, b % a)
}
init {
if (denominator == 0) throw ArithmeticException("Denominator cannot be zero")
var gcd = gcd(abs(numerator), abs(denominator))
if (denominator < 0) gcd = -gcd
this.numerator = numerator / gcd
this.denominator = denominator / gcd
}
override fun equals(other: Any?) =
when {
this === other -> true
other is Rational -> numerator == other.numerator && denominator == other.denominator
else -> false
}
}
Напомню, что операторы из анонимного инициализатора init
выполняются сразу же после создания объекта класса. Как видно из кода, мы в нём проверяем знаменатель на неравенство нулю, затем вычисляем наибольший общий делитель (GCD = greatest common divisor) числителя и знаменателя и сокращаем дробь на найденный наибольший общий делитель. Обратите вниманиие, что в тексте инициализатора numerator
является параметром конструктора Rational(numerator: Int, denominator: Int)
, а this.numerator
является свойством класса Rational
: val numerator: Int
.
Далее напишем функции для работы с рациональными числами. Их можно разделить на несколько групп.
Функции-операторы в Котлине описываются с помощью модификатора operator
. Их особенность в том, что они могут вызываться не только непосредственно в виде r1.plus(r2)
, но и с использованием знаков операций — в данном случае r1 + r2
. В языке Котлин принято соглашение, что функция, вызываемая с помощью оператора +
, обязана называться plus
, а также:
-
minus
соответствует операцииr1 - r2
-
unaryPlus
соответствует операции+r
-
unaryMinus
соответствует операции-r
-
times
соответствует операцииr1 * r2
-
div
соответствует операцииr1 / r2
-
другие подобные соглашения описаны здесь: https://kotlinlang.org/docs/reference/operator-overloading.html
Для реализации арифметических операций мы используем известные формулы, например a/b + c/d = (ad+bc)/bd
. Сокращение дробей будет автоматически выполняться в конструкторе. Таким образом:
operator fun plus(other: Rational) = Rational(
numerator * other.denominator + denominator * other.numerator,
denominator * other.denominator
)
Не забываем, что любая функция класса имеет фиктивный параметр, называемый "получателем". К его членам она обращается непосредственно: numerator
, denominator
(можно также писать this.numerator
и this.denominator
), а к членам other
через точку: other.numerator
, other.denominator
. При вызове вида r3 = r1.plus(r2)
или r3 = r1 + r2
r1
является получателем, значение аргумента r2
передаётся в параметр other
и, наконец, результат функции передаётся в r3
.
Эти функции очень просты. Единственное, что стоит запомнить — это их традиционное название toType()
, в данном случае toInt()
, toDouble()
. Например:
fun toDouble() = numerator.toDouble() / denominator
Вспомните здесь, для чего необходимо преобразование numerator.toDouble()
и почему нельзя написать просто numerator / denominator
.
Подобное сравнение довольно часто используется в библиотечных операциях, классическим примером является операция сортировки списка по возрастанию — val newList = list.sorted()
для неизменяемого списка или mutableList.sort()
для изменяемого списка. В результате её работы список вида [3, 6, 1, 4, 9]
превращается в отсортированный [1, 3, 4, 6, 9]
, а для выполнения сортировки необходимо уметь сравнивать элементы списка на больше / меньше. Подробнее об этом смотрите статью Википедии "Алгоритм сортировки", в качестве примера простой сортировки можете прочитать там же статью "Сортировка пузырьком".
Для описания и использования операций сравнения в Java и Котлине имеется специальный интерфейс "сравниваемый":
interface Comparable<T> {
// Возвращает положительное число, если получатель больше other,
// отрицательное число, если получатель меньше other,
// ноль, если они равны
fun compareTo(other: T): Int
}
Чаще всего для реализации сравнения тип Type
реализует Comparable<Type>
= сравниваемый с собой. Пример для рационального числа:
class Rational(numerator: Int, denominator: Int) : Comparable<Rational> {
// ...
override fun compareTo(other: Rational): Int {
val diff = this - other
// В зависимости от знака числителя разницы, this > other, this < other или this == other
return diff.numerator
}
}
Конечно же, для проверки нашей работы не обойтись без тестирования! Посмотрите тесты самостоятельно в test/lesson11/tesk1/RationalTest.kt
— например, начните с тестов для функции plus
. Считаете ли вы тесты достаточными? Почему? Если тесты недостаточны, то какие тестовые случаи вы бы добавили в список?
Важно: если вы учитесь в высшей школе INSYS, упражнения за уроки 11 и 12 являются материалами второго семестра. Рекомендуется ориентироваться на результат в 24-30 баллов суммарно за два урока 11 и 12.
Откройте каталог src/lesson11/task1
в проекте. Внутри находится файл с рассмотренным выше примером Rational.kt
, а также пять других файлов с различными заданиями на проектирование классов с данными. Задания различаются по сложности; самое простое из них находится в файле Complex.kt
, самое сложное — в файле UnsignedBigInteger.kt
. Суть каждого задания описана в заголовочном комментарии класса, плюс дан короткий комментарий к каждой функции класса.
Выберите одно из заданий, которое кажется вам посильным. Замените на реализацию все TODO()
, которые есть в классе. После этого откройте тесты для данного класса из каталога test/lesson11/task1
. Подумайте над тем, какие из важных случаев рассмотрены тестами, а какие — нет. Дополните тесты нерассмотренными случаями. После этого запустите тесты для вашего класса и добейтесь их полного прохождения.
При желании вы можете решить второе задание более высокой сложности. Далее переходите к уроку 12.