В этой главе представлено введение в моделирование предметной области с использованием объектно-ориентированного программирования (ООП) в Scala 3.
Введение
Scala предоставляет все необходимые инструменты для объектно-ориентированного проектирования:
- Traits позволяют указывать (абстрактные) интерфейсы, а также конкретные реализации.
- Mixin Composition предоставляет инструменты для создания компонентов из более мелких деталей.
- Классы могут реализовывать интерфейсы, заданные трейтами.
- Экземпляры классов могут иметь свое собственное приватное состояние.
- Subtyping позволяет использовать экземпляр одного класса там, где ожидается экземпляр его суперкласса.
- Модификаторы доступа позволяют управлять, к каким членам класса можно получить доступ с помощью какой части кода.
Трейты
В отличие от других языков с поддержкой ООП, таких как Java, возможно, основным инструментом декомпозиции в Scala являются не классы, а трейты. Они могут служить для описания абстрактных интерфейсов, таких как:
trait Showable {
def show: String
}
trait Showable:
def show: String
а также могут содержать конкретные реализации:
trait Showable {
def show: String
def showHtml = "<p>" + show + "</p>"
}
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
}
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
) только в Scala 3
Каждая из вышеперечисленных функций может быть использована для определения той или иной формы требований к реализатору trait
.
Смешанная композиция
Кроме того, что trait
-ы могут содержать абстрактные и конкретные определения,
Scala также предоставляет мощный способ создания нескольких trait
:
структура, которую часто называют смешанной композицией.
Предположим, что существуют следующие два (потенциально независимо определенные) trait
-а:
trait GreetingService {
def translate(text: String): String
def sayHello = translate("Hello")
}
trait TranslationService {
def translate(text: String): String = "..."
}
trait GreetingService:
def translate(text: String): String
def sayHello = translate("Hello")
trait TranslationService:
def translate(text: String): String = "..."
Чтобы скомпоновать два сервиса, можно просто создать новый trait
, расширяющий их:
trait ComposedService extends GreetingService with TranslationService
trait ComposedService extends GreetingService, TranslationService
Абстрактные элементы в одном trait
-е (например, translate
в GreetingService
)
автоматически сопоставляются с конкретными элементами в другом trait
-е.
Это работает не только с методами, как в этом примере, но и со всеми другими абстрактными членами,
упомянутыми выше (то есть типами, переменными и т.д.).
Классы
trait
-ы отлично подходят для модуляции компонентов и описания интерфейсов (обязательных и предоставляемых).
Но в какой-то момент возникнет необходимость создавать их экземпляры.
При разработке программного обеспечения в Scala часто бывает полезно рассмотреть возможность
использования классов только на начальных этапах модели наследования:
Трейты | T1 , T2 , T3 |
Составные трейты | S1 extends T1 with T2 , S2 extends T2 with T3 |
Классы | C extends S1 with T3 |
Экземпляры | new C() |
Трейты | T1 , T2 , T3 |
Составные трейты | S1 extends T1, T2 , S2 extends T2, T3 |
Классы | C extends S1, T3 |
Экземпляры | C() |
Это еще более актуально в Scala 3, где трейты теперь также могут принимать параметры конструктора, что еще больше устраняет необходимость в классах.
Определение класса
Подобно trait
-ам, классы могут расширять несколько trait
-ов (но только один суперкласс):
class MyService(name: String) extends ComposedService with Showable {
def show = s"$name says $sayHello"
}
class MyService(name: String) extends ComposedService, Showable:
def show = s"$name says $sayHello"
Подтипы
Экземпляр MyService
создается следующим образом:
val s1: MyService = new MyService("Service 1")
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
-ы разработаны как основное средство декомпозиции,
то не рекомендуется расширять класс, определенный в одном файле, из другого файла.
Открытые классы только в Scala 3
В Scala 3 расширение неабстрактных классов в других файлах ограничено.
Чтобы разрешить это, базовый класс должен быть помечен как open
:
open class Person(name: String)
Маркировка классов с помощью open
- это новая функция Scala 3.
Необходимость явно помечать классы как открытые позволяет избежать многих распространенных ошибок в ООП.
В частности, это требует, чтобы разработчики библиотек явно планировали расширение
и, например, документировали классы, помеченные как открытые.
Экземпляры и приватное изменяемое состояние
Как и в других языках с поддержкой ООП, трейты и классы в Scala могут определять изменяемые поля:
class Counter {
// получить значение можно только с помощью метода `count`
private var currentCount = 0
def tick(): Unit = currentCount += 1
def count: Int = currentCount
}
class Counter:
// получить значение можно только с помощью метода `count`
private var currentCount = 0
def tick(): Unit = currentCount += 1
def count: Int = currentCount
Каждый экземпляр класса Counter
имеет собственное приватное состояние,
которое можно получить только через метод count
, как показано в следующем взаимодействии:
val c1 = new Counter()
c1.count // 0
c1.tick()
c1.tick()
c1.count // 2
val c1 = Counter()
c1.count // 0
c1.tick()
c1.tick()
c1.count // 2
Модификаторы доступа
По умолчанию все определения элементов в Scala общедоступны.
Чтобы скрыть детали реализации, можно определить элементы (методы, поля, типы и т.д.) в качестве private
или protected
.
Таким образом, вы можете управлять доступом к ним или их переопределением.
Закрытые (private
) элементы видны только самому классу/трейту и его сопутствующему объекту.
Защищенные (protected
) элементы также видны для подклассов класса.
Дополнительный пример: сервис-ориентированный дизайн
Далее будут проиллюстрированы некоторые расширенные возможности Scala и показано, как их можно использовать для структурирования более крупных программных компонентов. Примеры взяты из статьи Мартина Одерски и Маттиаса Зенгера “Scalable Component Abstractions”. Пример в первую очередь предназначен для демонстрации того, как использовать несколько функций типа для создания более крупных компонентов.
Цель состоит в том, чтобы определить программный компонент с семейством типов,
которые могут быть уточнены позже при реализации компонента.
Конкретно, следующий код определяет компонент 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() = {
for ( obs <- observers ) obs.notify(this)
}
}
trait Observer {
def notify(sub: S): Unit
}
}
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() =
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 extends 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) = {
currentValue = v
publish()
}
}
class Display extends Observer {
def notify(sub: Sensor) =
println(s"${sub.label} has value ${sub.value}")
}
}
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) =
currentValue = v
publish()
class Display extends Observer:
def notify(sub: Sensor) =
println(s"${sub.label} has value ${sub.value}")
В частности, мы определяем singleton object SensorReader
, который расширяет SubjectObserver
.
В реализации SensorReader
говорится, что тип S
теперь определяется как тип Sensor
,
а тип O
определяется как тип Display
.
И Sensor
, и Display
определяются как вложенные классы в SensorReader
,
реализующие trait-ы Subject
и Observer
соответственно.
Помимо того, что этот код является примером сервис-ориентированного дизайна, он также освещает многие аспекты объектно-ориентированного программирования:
- Класс
Sensor
вводит свое собственное частное состояние (currentValue
) и инкапсулирует изменение состояния за методомchangeValue
. - Реализация
changeValue
использует методpublish
, определенный в родительском trait-е. - Класс
Display
расширяет traitObserver
и реализует отсутствующий методnotify
.
Важно отметить, что реализация notify
может безопасно получить доступ только к label
и значению sub
,
поскольку мы изначально объявили параметр типа S
.
Использование компонента
Наконец, следующий код иллюстрирует, как использовать компонент SensorReader
:
import SensorReader._
// настройка сети
val s1 = new Sensor("sensor1")
val s2 = new Sensor("sensor2")
val d1 = new Display()
val d2 = new Display()
s1.subscribe(d1)
s1.subscribe(d2)
s2.subscribe(d1)
// распространение обновлений по сети
s1.changeValue(2)
s2.changeValue(3)
// печатает:
// sensor1 has value 2.0
// sensor1 has value 2.0
// sensor2 has value 3.0
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)
s2.changeValue(3)
// печатает:
// sensor1 has value 2.0
// sensor1 has value 2.0
// sensor2 has value 3.0
Имея под рукой все утилиты объектно-ориентированного программирования, в следующем разделе будет продемонстрировано, как разрабатывать программы в функциональном стиле.
Contributors to this page:
Contents
- Введение
- Возможности Scala
- Почему Scala 3?
- Почувствуй Scala
- Пример 'Hello, World!'
- REPL
- Переменные и типы данных
- Структуры управления
- Моделирование данных
- Методы
- Функции первого класса
- Одноэлементные объекты
- Коллекции
- Контекстные абстракции
- Верхнеуровневые определения
- Обзор
- Первый взгляд на типы
- Интерполяция строк
- Структуры управления
- Моделирование предметной области
- Инструменты
- Моделирование ООП
- Моделирование ФП
- Методы
- Особенности методов
- Main методы в Scala 3
- Обзор
- Функции
- Анонимные функции
- Параметры функции
- Eta расширение
- Функции высшего порядка
- Собственный map
- Создание метода, возвращающего функцию
- Обзор
- Пакеты и импорт
- Коллекции в Scala
- Типы коллекций
- Методы в коллекциях
- Обзор
- Функциональное программирование
- Что такое функциональное программирование?
- Неизменяемые значения
- Чистые функции
- Функции — это значения
- Функциональная обработка ошибок
- Обзор
- Типы и система типов
- Определение типов
- Параметризованные типы
- Пересечение типов
- Объединение типов
- Алгебраические типы данных
- Вариантность
- Непрозрачные типы
- Структурные типы
- Зависимые типы функций
- Другие типы
- Контекстные абстракции
- Методы расширения
- Параметры контекста
- Контекстные границы
- Given импорты
- Классы типов
- Многостороннее равенство
- Неявное преобразование типов
- Обзор
- Параллелизм
- Scala утилиты
- Сборка и тестирование проектов Scala с помощью Sbt
- Рабочие листы
- Взаимодействие с Java