Scala. Профессиональное программирование 2022. Одерски Мартин, Спун Лекс, Веннерс Билл, Соммерс ФрэнкО41 Scala. Профессиональное программирование. 5е изд спб. Питер, 2022. 608 с. ил. Серия Библиотека программиста
Скачать 6.24 Mb.
|
204 Глава 10 • Композиция и наследование Как видите, элементы будут моделироваться с помощью типа данных по имени Element . Чтобы получить новый элемент, объединяющий два элемен та, вы можете вызвать в отношении элемента операторы above или beside , передавая им второй элемент. Например, выражение, показанное ниже, создаст более крупный элемент, содержащий два столбца, каждый высотой два элемента: val column1 = elem("hello") above elem("***") val column2 = elem("***") above elem("world") column1 beside column2 Вывод результата этого выражения даст следующий результат: hello *** *** world Элементы разметки — хороший пример системы, в которой объекты могут создаваться из простых частей с помощью операторов композиции. В данной главе будут определены классы, позволяющие создавать объекты элементов из векторов, рядов и прямоугольников. Эти объекты базовых элементов будут простыми деталями. Вдобавок будут определены операторы компо зиции above и beside . Такие операторы зачастую называют комбинаторами, поскольку они комбинируют элементы некой области в новые элементы. Подходить к проектированию библиотеки лучше всего в понятиях комби наторов: они позволяют осмыслить основные способы конструирования объектов в прикладной области. Что представляют собой простые объекты? Какими способами из простых объектов могут создаваться более интересные объекты? Как комбинаторы должны сочетаться друг с другом? Что должны представлять собой наиболее общие комбинации? Удовлетворяют ли они всем выдвигаемым правилам? Если у вас есть верные ответы на все эти во просы, то вы на правильном пути. 10 .2 . Абстрактные классы Нашей первой задачей будет определить тип Element , представляющий эле менты разметки. Элементы — двумерные прямоугольники из символов, по этому имеет смысл включить в класс метод по имени contents , ссылающийся на содержимое элемента разметки. Данный метод может быть представлен в виде векторов строк, где каждая строка обозначает ряд. Отсюда типом воз вращаемого contents значения должен быть Vector[String] . Как он будет выглядеть, показано в листинге 10.1. 10 .3 . Определяем методы без параметров 205 Листинг 10.1. Определение абстрактного метода и класса abstract class Element: def contents: Vector[String] В этом классе contents объявляется в качестве метода, у которого нет реа лизации. Иными словами, метод является абстрактным членом класса Element . Класс с абстрактными членами сам по себе должен быть объявлен абстрактным; это можно сделать, указав модификатор abstract перед клю чевым словом class : abstract class Element ... Модификатор abstract указывает на то, что у класса могут быть абстрактные члены, не имеющие реализации. Поэтому создать экземпляр абстрактного класса невозможно. При попытке сделать это будет получена ошибка ком пиляции: scala> new Element 1 |new Element | ˆˆˆˆˆˆˆ | Element is abstract; it cannot be instantiated Чуть позже в этой главе будет показан способ создания подклассов класса Element , экземпляры которых можно будет создавать, поскольку они запол няют отсутствующее определение метода contents Обратите внимание на то, что у метода contents класса Element нет моди фикатора abstract . Метод абстрактный, если у него нет реализации (то есть знака равенства и тела). В отличие от Java здесь при объявлении методов не нужны (не разрешены) модификаторы abstract . Методы, имеющие реали зацию, называются конкретными. И еще одно различие в терминологии между объявлениями и определениями. Класс Element объявляет абстрактный метод contents , но пока не определяет никаких конкретных методов. Но в следующем разделе класс Element будет усилен определением нескольких конкретных методов. 10 .3 . Определяем методы без параметров В качестве следующего шага к Element будут добавлены методы, пока зывающие его ширину ( width ) и высоту ( height ) (листинг 10.2). Метод height возвращает количество рядов в содержимом. Метод width возвра щает длину первого ряда или, при отсутствии рядов в элементе, ноль. Это 206 Глава 10 • Композиция и наследование значит, что нельзя определить элемент с нулевой высотой и ненулевой шириной. Листинг 10.2. Определение не имеющих параметров методов width и height abstract class Element: def contents: Vector[String] def height: Int = contents.length def width: Int = if height == 0 then 0 else contents(0).length Обратите внимание: ни в одном из трех методов класса Element нет списка параметров, даже пустого. Например, вместо def width(): Int метод определен без круглых скобок: def width: Int Такие методы без параметров встречаются в Scala довольно часто. В отли чие от них методы, определенные с пустыми круглыми скобками, например def height(): Int , называются методами с пустыми скобками. Согласно имеющимся рекомендациям методы без параметров следует использо вать, когда параметры отсутствуют и метод обращается к изменяемому состоянию только для чтения полей содержащего его объекта (притом он не модифицирует изменяемое состояние). По этому соглашению поддер живается принцип единообразного доступа [Mey00], который гласит, что на клиентский код не должен влиять способ реализации атрибута в виде поля или метода. Например, можно реализовать width и height в виде полей, а не методов, просто заменив def в каждом определении на val : abstract class Element: def contents: Vector[String] val height = contents.length val width = if height == 0 then 0 else contents(0).length С точки зрения клиента две пары определений абсолютно эквивалентны. Единственное различие заключается в том, что доступ к полю может осу ществляться немного быстрее вызова метода, поскольку значения полей предварительно вычислены при инициализации класса, а не вычисляются при каждом вызове метода. В то же время полям в каждом объекте Element требуется дополнительное пространство памяти. Поэтому вопрос о том, как лучше представить атрибут, в виде поля или в виде метода, зависит от способа использования класса клиентами, который со временем может изме 10 .3 . Определяем методы без параметров 207 ниться. Главное, чтобы на клиенты класса Element никак не воздействовали изменения, вносимые во внутреннюю реализацию класса. В частности, клиент класса Element не должен испытывать необходимости в перезаписи кода, если поле данного класса было переделано в функцию доступа, при условии, что это чистая функция доступа (то есть не имеет никаких побочных эффектов и не зависит от изменяемого состояния). Как бы то ни было, клиент не должен решать какиелибо проблемы. Пока у нас все получается. Но есть небольшое осложнение, связанное с мето дами работы Java и Scala 2. Дело в том, что в данных языках нет полноценной реализации принципа единообразного доступа. Например, string.length() в Java — не то же самое, что string.length , и даже array.length — не то же самое, что array.length() . Это может привести к путанице. Преодолеть это препятствие языку Scala 3 помогает то, что он весьма мягко относится к смешиванию методов, определенных в Java или Scala 2. В част ности, можно заменить метод без параметров методом с пустыми круглыми скобками и наоборот, если родительский класс был написан на Java или Scala 2. Можно также не ставить пустые круглые скобки при вызове любой функции, не получающей аргументов. Например, в Scala 3 одинаково допу стимо применение двух следующих строк кода: Array(1, 2, 3).toString "abc".length В принципе, в вызовах функций, определенных в Java или Scala 2, можно вообще не ставить пустые круглые скобки. Но их все же рекомендуется использовать, когда вызываемый метод представляет нечто большее, чем свойство своего объектаполучателя. Например, пустые круглые скобки уместны, если метод выполняет вводвывод, записывает переназначаемые переменные ( var переменные) или считывает var переменные, не явля ющиеся полями объектаполучателя, как непосредственно, так и косвенно, используя изменяемые объекты. Таким образом, список параметров служит визуальным признаком того, что вызов инициирует некие примечательные вычисления, например: "hello".length // нет (), поскольку побочные эффекты отсутствуют println() // лучше () не отбрасывать Подводя итоги, следует отметить, что в Scala приветствуется определение методов, не получающих параметров и не имеющих побочных эффектов, в виде методов без параметров, то есть без пустых круглых скобок. В то же время никогда не нужно определять метод, имеющий побочные эффекты, без 208 Глава 10 • Композиция и наследование круглых скобок, поскольку вызов этого метода будет выглядеть как выбор поля. Следовательно, ваши клиенты могут быть удивлены, столкнувшись с побочными эффектами. По аналогии с этим при вызове функции, имеющей побочные эффекты, не забудьте при написании вызова поставить пустые круглые скобки. Иначе говоря, если вызываемая функция выполняет операцию, то используйте круглые скобки, даже если компилятор этого не требует. Но если она про сто предоставляет доступ к свойству, то круглые скобки следует отбросить. 10 .4 . Расширяем классы Нам попрежнему нужна возможность создавать объектыэлементы. Вы уже видели, что новый класс Element не приспособлен для этого в силу своей абстрактности. Поэтому для получения экземпляра элемента необходимо создать подкласс, расширяющий класс Element и реализующий абстрактный метод contents . Как это делается, показано в листинге 10.3. Листинг 10.3. Определение класса ArrayElement в качестве подкласса класса Element class VectorElement(conts: Vector[String]) extends Element: def contents: Vector[String] = conts Класс ArrayElement определен в целях расширения класса Element . Как и в Java, для выражения данного обстоятельства после имени класса указы вается уточнение extends : ... extends Element ... Использование уточнения extends заставляет класс VectorElement унасле- довать у класса Element все его неприватные элементы и превращает тип VectorElement в подтип типа Element . Поскольку VectorElement расширяет Element , то класс VectorElement называется подклассом класса Element В свою очередь, Element — суперкласс VectorElement . Если не указать уточ нение extends , то компилятор Scala, безусловно, предположит, что ваш класс является расширением класса scala.AnyRef , который на платформе Java будет соответствовать классу java.lang.Object . Получается, класс Element неявно расширяет класс AnyRef . Эти отношения наследования показаны на рис. 10.1. Наследование означает, что все элементы суперкласса являются также эле ментами подкласса, но с двумя исключениями. Первое: приватные элементы 10 .4 . Расширяем классы 209 Рис. 10.1. Схема классов для VectorElement суперкласса не наследуются подклассом. Второе: элемент суперкласса не на следуется, если элемент с такими же именем и параметрами уже реализован в подклассе. В таком случае говорится, что элемент подкласса переопреде- ляет элемент суперкласса. Если элемент в подклассе является конкретным, а в суперклассе абстрактным, также говорится, что конкретный элемент — это реализация абстрактного элемента. Например, метод contents в VectorElement переопределяет (или, в ином толковании, реализует) абстрактный метод contents класса Element 1 . В от личие от этого класс VectorElement наследует у класса Element методы width и height . Например, располагая VectorElement объектом ae , можно запро сить его ширину, используя выражение ae.width , как будто метод width был определен в классе VectorElement 2 : val ve = VectorElement(Vector("hello", "world")) ve.width // 5 Создание подтипов означает, что значение подкласса может быть использо вано там, где требуется значение суперкласса. Например: val e: Element = VectorElement(Vector("hello")) Переменная e определена как принадлежащая типу Element , следовательно, значение, используемое для ее инициализации, также должно быть типа Element . А фактически типом этого значения является класс VectorElement 1 Один из недостатков данной конструкции заключается в том, что мы пока не га рантируем одинаковой длины массива contents каждого String элемента. Задачу можно решить, проверяя соблюдение предварительного условия в первичном конструкторе и генерируя исключения при его нарушении. 2 Как упоминалось в разделе 6.2, при создании экземпляров классов, принимающих параметры, таких как VectorElement , вы можете не использовать ключевое слово new 210 Глава 10 • Композиция и наследование Это нормально, поскольку он расширяет класс Element и в результате тип VectorElement совместим с типом Element 1 На рис. 10.1 также показано отношение композиции между VectorElement и Vector[String] . Оно так называется, поскольку класс VectorElement состо ит из Vector[String] , то есть компилятор Scala помещает в генерируемый им для VectorElement двоичный класс поле, содержащее ссылку на переданный массив conts Некоторые моменты, касающиеся композиции и наследования, будут рас смотрены чуть позже — в разделе 10.11. 10 .5 . Переопределяем методы и поля Принцип единообразного доступа является одним из тех аспектов, где Scala подходит к полям и методам единообразно — не так, как Java. Еще одно от личие заключается в том, что в Scala поля и методы принадлежат одному и тому же пространству имен. Этот позволяет полю переопределить метод без параметров. Например, можно, как показано в листинге 10.4, изменить реализацию contents в классе VectorElement из метода в поле, не модифици руя определение абстрактного метода contents в классе Element Листинг 10.4. Переопределение метода без параметров в поле class VectorElement(conts: Vector[String]) extends Element: val contents: Vector[String] = conts Поле contents (определенное с ключевым словом val ) в этой версии VectorEle ment — вполне подходящая реализация метода без параметров contents (объявленное с ключевым словом def ) в классе Element . В то же время в Scala запрещено в одном и том же классе определять поле и метод с одинаковыми именами, а в Java это разрешено. Например, этот класс в Java пройдет компиляцию вполне успешно: // Это код Java class CompilesFine { private int f = 0; public int f() { return 1; } } 1 Чтобы получить более четкое представление о разнице между подклассом и под типом, обратитесь к статье глоссария о подтипах. 10 .6 . Определяем параметрические поля 211 Но соответствующий класс в Scala не скомпилируется: class WontCompile: private var f = 0 // Не пройдет компиляцию, поскольку поле def f = 1 // и метод имеют одинаковые имена В принципе, в Scala вместо четырех имеющихся в Java пространств имен для определений имеются только два пространства. Четыре пространства имен в Java — это поля, методы, типы и пакеты. Напротив, двумя пространствами имен в Scala являются: z z значения (поля, методы, пакеты и объектыодиночки); z z типы (имена классов и трейтов). Причина, по которой поля и методы в Scala помещаются в одно и то же про странство имен, заключается в предоставлении возможности переопределить методы без параметров в val поля, чего нельзя сделать в Java 1 10 .6 . Определяем параметрические поля Рассмотрим еще раз определение класса VectorElement , показанное в преды дущем разделе. В нем имеется параметр conts , единственное предназначение которого — его копирование в поле contents . Имя conts было выбрано для параметра, чтобы походило на имя поля contents , но не вступало с ним в кон фликт имен. Это «код с душком» — признак того, что в вашем коде может быть некая совершенно ненужная избыточность и повторяемость. От этого кода сомнительного качества можно избавиться, скомбинировав параметр и поле в едином определении параметрического поля, что и по казано в листинге 10.5. Листинг 10.5. Определение contents в качестве параметрического поля // Расширенный элемент, показанный в листинге 10.2. class VectorElement( val contents: Vector[String] ) extends Element 1 Причиной того, что пакеты в Scala используют общее с полями и методами про странство имен, является стремление предоставить вам возможность получать доступ к импорту пакетов (а не только к именам типов), а также к полям и мето дам объектоводиночек. Это тоже входит в перечень того, что невозможно сделать в Java. Подробности будут рассмотрены в разделе 12.3. 212 Глава 10 • Композиция и наследование Обратите внимание: параметр contents имеет префикс val . Это сокращенная форма записи, определяющая одновременно параметр и поле с одним и тем же именем. Если выразиться более конкретно, то класс VectorElement теперь имеет поле contents (непереназначаемое), доступ к которому может быть получен за пределами класса. Поле инициализировано значением параметра. Похоже на то, будто бы класс был написан следующим образом: class VectorElement(x123: Vector[String]) extends Element: val contents: Vector[String] = x123 где x123 — произвольное имя для параметра. Кроме того, можно поставить перед параметром класса префикс var , и тогда соответствующее поле станет переназначаемым. И наконец, подобным пара метризованным полям, как и любым другим членам класса, можно добавлять такие модификаторы, как private , protected 1 или override . Рассмотрим, к примеру, следующие определения классов: class Cat: val dangerous = false class Tiger( override val dangerous: Boolean, private var age: Int ) extends Cat Определение класса Tiger — сокращенная форма для следующего альтер нативного определения класса с переопределяемым элементом dangerous и приватным элементом age : class Tiger(param1: Boolean, param2: Int) extends Cat: override val dangerous = param1 private var age = param2 Оба элемента инициализируются соответствующими параметрами. Имена для этих параметров, param1 и param2 , были выбраны произвольно. Главное, чтобы они не конфликтовали с какимилибо другими именами в пространстве имен. 10 .7 . Вызываем конструктор суперкласса Теперь вы располагаете полноценной системой из двух классов: абстрактного класса Element , который расширяется конкретным классом VectorElement 1 Модификатор protected , предоставляющий доступ к подклассам, будет подробно рассмотрен в главе 12. |