Моделирование ООП
Введение
Scala предоставляет все необходимые инструменты для объектно-ориентированного проектирования:
- Traits позволяют указывать (абстрактные) интерфейсы, а также конкретные реализации.
- Mixin Composition предоставляет инструменты для создания компонентов из более мелких деталей.
- Классы могут реализовывать интерфейсы, заданные
traits
. - Экземпляры классов могут иметь свое собственное приватное состояние.
- Subtyping позволяет использовать экземпляр одного класса там, где ожидается экземпляр его суперкласса.
- Модификаторы доступа позволяют управлять, к каким членам класса можно получить доступ с помощью какой части кода.
Traits
Возможно, в отличие от других языков программирования с поддержкой ООП таких, как Java,
основным инструментом декомпозиции в Scala являются не классы, а traits
.
Они могут служить для описания абстрактных интерфейсов, таких как:
trait Showable:
def show: String
, а также могут содержать конкретные реализации:
trait Showable:
def show: String
def showHtml = "<p>" + show + "</p>"
На примере видно, что метод showHtml
определяется в терминах абстрактного метода show
.
Odersky и Zenger представляют сервис-ориентированную компонентную модель и рассматривают:
- абстрактные члены как требуемые службы: их все еще необходимо реализовать в подклассе.
- конкретные члены как предоставляемые услуги: они предоставляются подклассу.
Это видно на примере со Showable
: определяя класс Document
, который расширяет Showable
,
все еще нужно определить show
, но showHtml
уже предоставляется:
class Document(text: String) extends Showable:
def show = text
Абстрактные методы
Абстрактными в trait
могут оставаться не только методы. trait
может содержать:
- абстрактные методы (
def m(): T
) - абстрактные переменные (
val x: T
) - абстрактные типы (
type T
), потенциально с ограничениями (type T <: S
) - абстрактные given (
given t: T
) - подробнее обgiven
в следующих главах
Каждая из вышеперечисленных функций может быть использована для определения той или иной формы требований к реализатору trait
.
Mixin Composition
Кроме того, что trait
-ы могут содержать абстрактные и конкретные определения,
Scala также предоставляет мощный способ создания нескольких trait
:
структура, которую часто называют смешанной композицией.
Предположим, что следующие два (потенциально независимо определенные) trait
-а:
trait GreetingService:
def translate(text: String): String
def sayHello = translate("Hello")
trait TranslationService:
def translate(text: String): String = "..."
Чтобы скомпоновать два сервиса, можно просто создать новый trait
, расширяющий их:
trait ComposedService extends GreetingService, TranslationService
Абстрактные элементы в одном trait
-е (например, translate
в GreetingService
)
автоматически сопоставляются с конкретными элементами в другом trait
-е.
Это работает не только с методами, как в этом примере,
но и со всеми другими абстрактными членами, упомянутыми выше (то есть типами, переменными и т.д.).
Классы
trait
-ы отлично подходят для модуляции компонентов и описания интерфейсов (обязательных и предоставляемых).
Но в какой-то момент возникнет необходимость создавать их экземпляры.
При разработке программного обеспечения в Scala часто бывает полезно рассмотреть возможность использования классов
только на начальных этапах модели наследования:
Traits - T1
, T2
, T3
Composed traits - S extends T1, T2
, S extends T2, T3
Classes - C extends S, T3
Instances - C()
Это еще более актуально в Scala 3, где trait
-ы теперь также могут принимать параметры конструктора,
что еще больше устраняет необходимость в классах.
Определение класса
Подобно trait
-ам, классы могут расширять несколько trait
-ов (но только один суперкласс):
class MyService(name: String) extends ComposedService, Showable:
def show = s"$name says $sayHello"
Subtyping
Экземпляр MyService
создается следующим образом:
val s1: MyService = MyService("Service 1")
С помощью подтипов экземпляр s1
можно использовать везде, где ожидается любое из расширенных свойств:
val s2: GreetingService = s1
val s3: TranslationService = s1
val s4: Showable = s1
// ... и т.п. ...
Планирование расширения
Как упоминалось ранее, можно расширить еще один класс:
class Person(name: String)
class SoftwareDeveloper(name: String, favoriteLang: String)
extends Person(name)
Однако, поскольку trait
-ы разработаны как основное средство декомпозиции,
класс, определенный в одном файле, не может быть расширен в другом файле.
Чтобы разрешить это, базовый класс должен быть помечен как открытый:
open class Person(name: String)
Маркировка классов с помощью open
- это новая функция Scala 3.
Необходимость явно помечать классы как открытые позволяет избежать многих распространенных ошибок в ООП.
В частности, это требует, чтобы разработчики библиотек явно планировали расширение
и, например, документировали классы, помеченные как открытые.
Пример:
// File Writer.scala
package p
open class Writer[T]:
/** Sends to stdout, can be overridden */
def send(x: T) = println(x)
/** Sends all arguments using `send` */
def sendAll(xs: T*) = xs.foreach(send)
end Writer
// File EncryptedWriter.scala
package p
class EncryptedWriter[T: Encryptable] extends Writer[T]:
override def send(x: T) = super.send(encrypt(x))
Открытый класс обычно поставляется с некоторой документацией, описывающей внутренние шаблоны вызовов между методами класса, а также хуки, которые можно переопределить. Это называется контрактом расширения класса (extension contract). Он отличается от внешнего контракта (external contract) между классом и его пользователями.
Классы без модификатора open
все же могут быть расширены,
но только при соблюдении хотя бы одного из двух альтернативных условий:
- Расширяющий класс находится в том же исходном файле, что и расширенный класс. В этом случае расширение обычно является внутренним вопросом реализации.
- Для класса расширения включена языковая функция
adhocExtensions.
Обычно она включается предложением импорта в исходном файле расширения:
import scala.language.adhocExtensions
Кроме того, эту функцию можно включить с помощью опции компилятора-language:adhocExtensions
. Если эта функция не включена, компилятор выдаст "feature" warning.
Экземпляры и приватное изменяемое состояние
Как и в других языках с поддержкой ООП, trait
-ы и классы в Scala могут определять изменяемые поля:
class Counter:
private var currentCount = 0
def tick(): Unit = currentCount += 1
def count: Int = currentCount
Каждый экземпляр класса Counter
имеет собственное приватное состояние,
которое можно наблюдать только через метод count
, как показано в следующем примере:
val c1 = Counter()
c1.count
// res0: Int = 0
c1.tick()
c1.tick()
c1.count
// res3: Int = 2
Модификаторы доступа
По умолчанию все определения элементов в Scala являются общедоступными.
Чтобы скрыть детали реализации, можно определить элементы (методы, поля, типы и т.д.) как private
или protected
.
Таким образом можно контролировать, как к ним обращаются или как их переопределяют.
Закрытые (private
) члены видны только самому классу/trait
-у и его сопутствующему объекту.
Защищенные (protected
) члены также видны подклассам класса.
Дополнительный пример: сервис-ориентированный дизайн
Далее будут проиллюстрированы некоторые расширенные возможности Scala и показано, как их можно использовать для структурирования более крупных программных компонентов. Примеры взяты из статьи Мартина Одерски и Маттиаса Зенгера Масштабируемые компонентные абстракции. Пример в первую очередь предназначен для демонстрации того, как использовать несколько функций типа для создания более крупных компонентов.
Цель состоит в том, чтобы определить программный компонент с семейством типов,
которые могут быть уточнены позже при реализации компонента.
Конкретно, следующий код определяет компонент SubjectObserver
как trait
с двумя членами абстрактного типа,
S
(для субъектов) и O
(для наблюдателей):
trait SubjectObserver:
type S <: Subject
type O <: Observer
trait Subject { self: S =>
private var observers: List[O] = List()
def subscribe(obs: O): Unit =
observers = obs :: observers
def publish(): Unit =
for obs <- observers do obs.notify(this)
}
trait Observer:
def notify(sub: S): Unit
Есть несколько вещей, которые нуждаются в объяснении.
Члены абстрактного типа
Тип объявления S <: Subject
говорит, что внутри trait
SubjectObserver
можно ссылаться
на некоторый неизвестный (то есть абстрактный) тип, который называется S
.
Однако этот тип не является полностью неизвестным: мы знаем, по крайней мере, что это какой-то подтип Subject
.
Все trait
-ы и классы, расширяющие SubjectObserver
, могут свободно выбирать любой тип для S
,
если выбранный тип является подтипом Subject
.
Часть <: Subject
декларации также упоминается как верхняя граница на S
.
Вложенные trait
-ы
В рамках trait
-а SubjectObserver
определяются два других trait
-а.
trait
Observer
, который определяет только один абстрактный метод notify
с одним аргументом типа S
.
Как будет видно, важно, чтобы аргумент имел тип S
, а не тип Subject
.
Второй trait
, Subject
, определяет одно приватное поле observers
для хранения всех наблюдателей,
подписавшихся на этот конкретный объект. Подписка на объект просто сохраняет объект в списке.
Опять же, тип параметра obs
- это O
, а не Observer
.
Аннотации собственного типа
Наконец, что означает self: S =>
в trait
-е Subject
? Это называется аннотацией собственного типа.
И требует, чтобы подтипы Subject
также были подтипами S
.
Это необходимо, чтобы иметь возможность вызывать obs.notify
с this
в качестве аргумента,
поскольку для этого требуется значение типа S
.
Если бы S
был конкретным типом, аннотацию собственного типа можно было бы заменить на trait Subject
,
расширяющий S
.
Реализация компонента
Теперь можно реализовать вышеуказанный компонент и определить члены абстрактного типа как конкретные типы:
object SensorReader extends SubjectObserver:
type S = Sensor
type O = Display
class Sensor(val label: String) extends Subject:
private var currentValue = 0.0
def value = currentValue
def changeValue(v: Double): Unit =
currentValue = v
publish()
class Display extends Observer:
def notify(sub: Sensor): Unit =
println(s"${sub.label} has value ${sub.value}")
В частности, мы определяем singleton object SensorReader
, который расширяет SubjectObserver
.
В реализации SensorReader
говорится, что type
S
теперь определяется как type
Sensor
,
а type
O
определяется как type
Display
.
И Sensor
, и Display
определяются как вложенные классы в SensorReader
,
реализующие trait
-ы Subject
и Observer
соответственно.
Помимо того, что этот код является примером сервис-ориентированного дизайна, он также освещает многие аспекты объектно-ориентированного программирования:
- Класс
Sensor
вводит свое собственное частное состояние (currentValue
) и инкапсулирует изменение состояния за методомchangeValue
. - Реализация
changeValue
использует методpublish
, определенный в родительскомtrait
-е. - Класс
Display
расширяетtrait
Observer
и реализует отсутствующий методnotify
.
Важно отметить, что реализация notify
может безопасно получить доступ только к label
и value
sub
,
поскольку мы изначально объявили параметр типа S
.
Использование компонента
Наконец, следующий код иллюстрирует, как использовать компонент SensorReader
:
import SensorReader.*
// настройка сети
val s1 = Sensor("sensor1")
val s2 = Sensor("sensor2")
val d1 = Display()
val d2 = Display()
s1.subscribe(d1)
s1.subscribe(d2)
s2.subscribe(d1)
// распространение обновлений по сети
s1.changeValue(2)
// sensor1 has value 2.0
// sensor1 has value 2.0
s2.changeValue(3)
// sensor2 has value 3.0
Имея под рукой все утилиты объектно-ориентированного программирования, в следующем разделе будет продемонстрировано, как разрабатывать программы в функциональном стиле.
Ссылки: