Skip to content

Latest commit

 

History

History
145 lines (106 loc) · 15.1 KB

chapter11.adoc

File metadata and controls

145 lines (106 loc) · 15.1 KB

11. Разработка сложных классов с данными

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

Из раздела 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.