Scala. Профессиональное программирование 2022. Одерски Мартин, Спун Лекс, Веннерс Билл, Соммерс ФрэнкО41 Scala. Профессиональное программирование. 5е изд спб. Питер, 2022. 608 с. ил. Серия Библиотека программиста
Скачать 6.24 Mb.
|
233 мешать расширяющий трейт в класс, реализовав «тонкую» часть интерфейса, и получить в результате класс, позволяющий обеспечить доступ ко всему доступному «толстому» интерфейсу. Хорошим примером области, в которой «толстый» интерфейс удобен, яв ляется сравнение. Сравнивая два упорядочиваемых объекта, было бы рационально воспользо ваться вызовом одного метода, чтобы выяснить результаты желаемого срав нения. Если нужно использовать сравнение «меньше», то предпочтительнее было бы вызвать < , а если требуется применить сравнение «меньше или равно» — вызвать <= . С «тонким» интерфейсом можно располагать только методом < , и тогда временами приходилось бы создавать код наподобие (x < y) || (x == y) . «Толстый» интерфейс предоставит вам все привычные операторы сравнения, позволяя напрямую создавать код вроде x <= y Прежде чем посмотреть на трейт Ordered , представим, что можно сделать без него. Предположим, вы взяли класс Rational из главы 6 и добавили к нему операции сравнения. У вас должен получиться примерно такой код 1 : class Rational(n: Int, d: Int): // ... def < (that: Rational) = this.numer * that.denom < that.numer * this.denom def > (that: Rational) = that < this def <= (that: Rational) = (this < that) || (this == that) def >= (that: Rational) = (this > that) || (this == that) В данном классе определяются четыре оператора сравнения ( < , > , <= и >= ), и это классическая демонстрация стоимости определения «толстого» ин терфейса. Сначала обратите внимание на то, что три оператора сравнения определены на основе первого оператора. Например, оператор > определен как противоположность оператора < , а оператор <= определен буквально как «меньше или равно». Затем обратите внимание на то, что все три этих метода будут такими же для любого другого класса, объекты которого могут сравниваться друг с другом. В отношении оператора <= для рациональных чисел не прослеживается никаких особенностей. В контексте сравнения оператор <= всегда используется для обозначения «меньше или равно». В общем, в этом классе есть довольно много шаблонного кода, который будет точно таким же в любом другом классе, реализующем операции сравнения. 1 Этот пример основан на использовании класса Rational , показанного в листин ге 6.5, с методами equals , hashCode и модификациями, гарантирующими положи тельный denom 234 Глава 11 • Трейты Данная проблема встречается настолько часто, что в Scala предоставляет ся трейт, помогающий справиться с ее решением. Этот трейт называется Ordered . Чтобы воспользоваться им, нужно заменить все отдельные методы сравнений одним методом compare . Затем на основе одного этого метода определить в трейте Ordered методы < , > , <= и >= . Таким образом, трейт Ordered позволит вам расширить класс методами сравнений с помощью реализации всего одного метода по имени compare Если определить операции сравнения в Rational путем использования трей та Ordered , то код будет иметь следующий вид: class Rational(n: Int, d: Int) extends Ordered[Rational]: // ... def compare(that: Rational) = (this.numer * that.denom) - (that.numer * this.denom) Нужно выполнить две задачи. Начнем с того, что в Rational примешивается трейт Ordered . В отличие от трейтов, которые встречались до сих пор, Ordered требует от вас при примешивании указать параметр типа. Параметры ти пов до главы 18 подробно рассматриваться не будут, а пока все, что нужно знать при примешивании Ordered , сводится к следующему: фактически следует выполнять примешивание Ordered[C] , где C обозначает класс, эле менты которого сравниваются. В данном случае в Rational примешивается Ordered[Rational] Вторая задача, требующая выполнения, заключается в определении метода compare для сравнения двух объектов. Этот метод должен сравнивать полу чатель this с объектом, переданным методу в качестве аргумента. Возвра щать он должен целочисленное значение, которое равно нулю, если объекты одинаковы, отрицательное число, если получатель меньше аргумента, и по ложительное, если получатель больше аргумента. В данном случае метод сравнения класса Rational использует формулу, основанную на приведении чисел к общему знаменателю с последующим вычитанием получившихся числителей. Теперь при наличии этого при мешивания и определения метода compare класс Rational имеет все четыре метода сравнения: val half = new Rational(1, 2) val third = new Rational(1, 3) half < third // false half > third // true При каждой реализации класса с какойлибо возможностью упорядочен ности путем сравнения нужно рассматривать вариант примешивания в него 11 .3 . Трейты как наращиваемые модификации 235 трейта Ordered . Если выполнить это примешивание, то пользователи класса получат богатый набор методов сравнений. Имейте в виду, что трейт Ordered не определяет за вас метод equals , посколь ку не способен на это. Дело в том, что реализация equals в терминах compare требует проверки типа переданного объекта, а изза удаления типов сам Ordered не может ее выполнить. Поэтому определять equals вам придется самим, даже если вы примешиваете трейт Ordered 11 .3 . Трейты как наращиваемые модификации Основное применение трейтов — превращение «тонкого» интерфейса в «тол стый» — вы уже видели. Перейдем теперь ко второму по значимости способу использования трейтов — предоставлению классам наращиваемых модифи каций. Трейты дают возможность изменять методы класса, позволяя вам наращивать их друг на друге. Рассмотрим в качестве примера наращиваемые модификации применитель но к очереди целых чисел. В очереди будут две операции: put , помещающая целые числа в очередь, и get , извлекающая их из очереди. Очереди работают по принципу «первым пришел, первым ушел», поэтому целые числа извле каются из очереди в том же порядке, в котором были туда помещены. Располагая классом, реализующим такую очередь, можно определить трейты для выполнения следующих модификаций: z z удваивания — удваиваются все целые числа, помещенные в очередь; z z увеличения на единицу — увеличиваются все целые числа, помещенные в очередь; z z фильтрации — из очереди отфильтровываются все отрицательные целые числа. Эти три трейта представляют модификации, поскольку модифицируют по ведение соответствующего класса очереди, а не определяют полный класс очереди. Все три трейта также являются наращиваемыми. Можно выбрать любые из трех трейтов, примешать их в класс и получить новый класс, об ладающий всеми выбранными модификациями. В листинге 11.5 показан абстрактный класс IntQueue . В IntQueue имеются метод put , добавляющий к очереди новые целые числа, и метод get , который 236 Глава 11 • Трейты возвращает целые числа и удаляет их из очереди. Основная реализация IntQueue , которая использует ArrayBuffer , показана в листинге 11.6. Листинг 11.5. Абстрактный класс IntQueue abstract class IntQueue: def get(): Int def put(x: Int): Unit Листинг 11.6. Реализация класса BasicIntQueue с использованием ArrayBuffer import scala.collection.mutable.ArrayBuffer class BasicIntQueue extends IntQueue: private val buf = ArrayBuffer.empty[Int] def get() = buf.remove(0) def put(x: Int) = buf += x В классе BasicIntQueue имеется приватное поле, содержащее буфер в виде массива. Метод get удаляет запись с одного конца буфера, а метод put до бавляет элементы к другому его концу. Пример использования данной реа лизации выглядит следующим образом: val queue = new BasicIntQueue queue.put(10) queue.put(20) queue.get() // 10 queue.get() // 20 Пока все вроде бы в порядке. Теперь посмотрим на использование трейтов для модификации данного поведения. В листинге 11.7 показан трейт, кото рый удваивает целые числа по мере их помещения в очередь. Трейт Doubling имеет две интересные особенности. Первая заключается в том, что в нем объявляется суперкласс IntQueue . Это объявление означает, что трейт может примешиваться только в класс, который также расширяет IntQueue . То есть Doubling можно примешивать в BasicIntQueue , но не в Rational Листинг 11.7. Трейт наращиваемых модификаций Doubling trait Doubling extends IntQueue: abstract override def put(x: Int) = super.put(2 * x) Вторая интересная особенность заключается в том, что у трейта имеется вызов super в отношении метода, объявленного абстрактным. Для обычных классов такие вызовы применять запрещено, поскольку во время выполне ния они гарантированно дадут сбой. Но для трейта такой вызов может дей ствительно пройти успешно. В трейте вызовы super динамически связаны, поэтому вызов super в трейте Doubling будет работать при условии, что трейт 11 .3 . Трейты как наращиваемые модификации 237 примешан после другого трейта или класса, в котором дается конкретное определение метода. Трейтам, которые реализуют наращиваемые модификации, зачастую нужен именно такой порядок. Чтобы сообщить компилятору, что это делается на меренно, подобные методы следует помечать модификаторами abstract override . Это сочетание модификаторов позволительно только для членов трейтов, но не классов и означает, что трейт должен быть примешан в некий класс, имеющий конкретное определение рассматриваемого метода. Применение трейта выглядит следующим образом: class MyQueue extends BasicIntQueue, Doubling val queue = new MyQueue queue.put(10) queue.get() // 20 В первой строке этого примера определяется класс MyQueue , котоый рас ширяет класс BasicIntQueueand , примешивая в него трейт Doubling . Затем мы создаем новый MyQueue и помещаем в него число 10 , но в результате при мешивания трейта Doubling оно удваивается. При извлечении целого числа из очереди оно уже имеет значение 20 Обратите внимание: в MyQueue не определяется никакой новый код — про сто объявляется класс и примешивается трейт. В подобной ситуации вместо определения именованного класса код BasicIntQueue with Doubling может быть предоставлен непосредственно с ключевым словом new . Работа такого кода показана в листинге 11.8 1 Листинг 11.8. Примешивание трейта при создании экземпляра с помощью ключевого слова new val queue = new BasicIntQueue with Doubling queue.put(10) queue.get() // 20 Чтобы посмотреть, как нарастить модификации, нужно определить еще два модифицирующих трейта: Incrementing и Filtering . Реализация этих трейтов показана в листинге 11.9. Листинг 11.9. Трейты наращиваемых модификаций Incrementing и Filtering trait Incrementing extends IntQueue: abstract override def put(x: Int) = super.put(x + 1) 1 Вы должны использовать with , а не запятые, примешивая трейты в анонимный класс. 238 Глава 11 • Трейты trait Filtering extends IntQueue: abstract override def put(x: Int) = if x >= 0 then super.put(x) Теперь, располагая модифицирующими трейтами, можно выбрать, какой из них вам понадобится для той или иной очереди. Например, ниже показана очередь, в которой не только отфильтровываются отрицательные числа, но и ко всем сохраняемым числам прибавляется единица: val queue = new BasicIntQueue with Incrementing with Filtering queue.put(-1) queue.put(0) queue.put(1) queue.get() // 1 queue.get() // 2 Порядок примешивания играет существенную роль 1 . Конкретные правила даны в следующем разделе, но, грубо говоря, трейт, находящийся правее, вступает в силу первым. Когда метод вызывается в отношении экземпляра класса с примешанными трейтами, первым вызывается тот метод, который определен в самом правом трейте. Если этот метод выполняет вызов super , то вызывается метод, который определен в следующем трейте левее данного трейта, и т. д. В предыдущем примере сначала вызывается метод put трейта Filtering , следовательно, все начинается с того, что он удаляет отрица тельные целые числа. Вторым вызывается метод put трейта Incrementing , следовательно, к оставшимся целым числам прибавляется единица. Если расположить трейты в обратном порядке, то сначала к целым числам будет прибавляться единица и только потом те целые числа, которые все же останутся отрицательными, будут удалены: val queue = new BasicIntQueue with Filtering with Incrementing queue.put(-1) queue.put(0) queue.put(1) queue.get() // 0 queue.get() // 1 queue.get() // 2 В общем, код, создаваемый в данном стиле, открывает перед вами широкие возможности для проявления гибкости. Примешивая эти три трейта в раз ных сочетаниях и разном порядке следования, можно определить 16 раз личных классов. Весьма впечатляющая гибкость для столь незначительного 1 После того как трейт примешан к классу, вы также можете назвать его миксином. 11 .4 . Почему не используется множественное наследование 239 объема кода, поэтому постарайтесь не проглядеть возможности организации кода в целях получения наращиваемых модификаций. 11 .4 . Почему не используется множественное наследование Трейты позволяют наследовать из множества похожих на классы конструк ций, но имеют весьма важные отличия от множественного наследования, имеющегося во многих языках программирования. Одно из отличий, интерпретация super , играет особенно важную роль. При использовании множественного наследования метод, вызванный с помо щью вызова super , может быть определен прямо там, где появляется этот вызов. При использовании трейтов вызываемый метод определяется путем линеаризации, то есть выстраивания в ряд классов и трейтов, примешанных в класс. Это то самое рассмотренное в предыдущем разделе отличие, которое позволяет выполнять наращивание модификаций. Прежде чем рассмотреть линеаризацию, немного отвлечемся на то, как наращиваемые модификации выполняются в языке с традиционным мно жественным наследованием. Представим следующий код, однако на этот раз интерпретируемый не как примешивание трейтов, а как множественное наследование: // Мысленный эксперимент с множественным наследованием val q = new BasicIntQueue with Incrementing with Doubling q.put(42) // Который из методов put будет вызван? Сразу возникает вопрос: который из методов put будет задействован в этом вызове? Возможно, вступят в силу правила, согласно которым победу одер жит самый последний суперкласс. В таком случае будет вызван метод из Doubling . В данном методе будет удвоен его аргумент, сделан вызов super.put , и на этом все. Не произойдет никакого увеличения на единицу! Кроме того, если бы действовало правило, при котором побеждал бы первый суперкласс, то в получающейся очереди целые числа увеличивались бы на единицу, но не удваивались. То есть не срабатывало бы никакое упорядочение. Можно подумать и о том, как предоставить программистам возможность точно указывать при использовании вызова super , из какого именно супер класса им нужен метод. На самом деле вы можете сделать это в Scala, указав суперкласс в квадратных скобках после super . Вот пример, в котором реали зации put явно вызываются и для Incrementing , и для Doubling : 240 Глава 11 • Трейты // Мысленный эксперимент с множественным наследованием trait MyQueue extends BasicIntQueue, Incrementing, Doubling: def put(x: Int) = super[Incrementing].put(x) // (используется редко, super[Doubling].put(x) // но допускается в Scala) Если бы это был единственный подход Scala, то он породил бы новые пробле мы (самой малой из которых будет многословие). В таком случае получается, что метод put базового класса вызывается дважды: один раз со значением, увеличенным на единицу, и один раз с удвоенным значением, но никогда с увеличенным и удвоенным значением. Сравнение с методами Java по умолчанию Начиная с Java 8, вы можете включать методы по умолчанию в интер фейсы. Хотя они и напоминают конкретные методы в трейтах Scala, но сильно отличаются, потому что Java не выполняет линеаризацию. Поскольку интерфейс не может указывать сегменты или расширять суперкласс, отличающийся от Object , метод по умолчанию может получить доступ к состоянию объекта только путем вызова методов интерфейса, реализованных подклассом. Напротив, конкретные методы в трейте Scala могут получить доступ к состоянию объекта через сегменты, объявленные в трейте, или путем вызова методов с super , которые получают доступ к сегментам супертрейтов или суперклассов. Кроме того, если вы прописываете класс Java, который наследует методы по умолчанию с одинаковыми подписями из двух разных суперинтерфейсов, Java потребует, чтобы вы самостоятельно реализовали этот метод в классе. Ваше внедрение может вызывать один или оба метода по умолчанию, указывая имя интерфейса перед super , например "Doubling.super.put(x)" . Для сравнения: Scala гарантирует, что ваш класс наследует ближайшую реализацию в ли неаризации. В Java методы по умолчанию направлены на то, чтобы разработчики библиотек могли добавлять методы к существующим интерфейсам. До Java 8 это было нецелесообразно, поскольку нарушало бинарную (двоичную) совместимость любого класса, реализующего интерфейс. Теперь же Java может использовать реализацию по умолчанию, если класс не предоставляет ее и даже если класс не был перекомпилирован с момента добавления нового метода в интерфейс. 11 .4 . Почему не используется множественное наследование 241 При использовании множественного наследования данная задача просто не имеет правильного решения. Придется опять возвращаться к проектирова нию и реорганизовывать код. В отличие от этого с решением на основе при менения трейтов в Scala все предельно понятно. Вы просто примешиваете трейты Incrementing и Doubling , и имеющееся в Scala особое правило, кото рое касается применения super в трейтах, позволяет добиться всего, чего вы хотели. Нечто здесь очевидно отличается от традиционного множественного наследования, но что именно? Согласно уже данной подсказке ответом будет линеаризация. Когда с помо щью ключевого слова new создается экземпляр класса, Scala берет класс со всеми его унаследованными классами и трейтами и располагает их в едином линейном порядке. Затем при любом вызове super внутри одного из таких классов вызывается тот метод, который идет следующим по порядку. Если во всех методах, кроме последнего, присутствует вызов super , то получается наращивание. Описание конкретного порядка линеаризации дается в спецификации языка. Он сложноват, но вам нужно знать лишь главное: при любой линеаризации класс всегда следует впереди всех своих суперклассов и примешанных трейтов. Таким образом, при написании метода, содержащего вызов super , этот метод изменяет поведение суперкласса и примешанных трейтов, а не наоборот. |