Представление концепций ООП с реальными сценариями

Представьте, что вы архитектор, перед вами чистый хост, и вы полны идей о том, как сделать из него идеальный дом. Но с чего начать? Как организовать все эти идеи и превратить их во что-то конкретное?

Введение

Здесь пригодится ООП, то есть объектно-ориентированное программирование: им уменьшается сложность разработки ПО. Архитектор придает дому структуру, этим мощным инструментом в код тоже привносится структура.

Разбивая программу на управляемые отдельные части поменьше, объекты с четкими обязанностями, вы создаете более эффективное, гибкое и масштабируемое ПО, адекватное задачам пользователей.

Как применить к проектам концепции ООП? Чтобы разобраться, окунемся в них.

Что такое «концепции ООП»

Концепции объектно-ориентированного программирования  —  это фундаментальные идеи и принципы, которыми определяется парадигма ООП. Эти концепции  —  инкапсуляция, наследование, полиморфизм и абстракция  —  применяются для организации и структурирования разработки ПО, упрощая создание и сопровождение сложных приложений.

Что такое «инкапсуляция»

Инкапсуляция  —  это процесс сокрытия от внешнего мира деталей реализации объекта и предоставления простого интерфейса для взаимодействия с этим объектом. Сюда относятся группировка связанных данных и методов в единое целое и контроль доступа к ним с помощью модификаторов видимости private, public и protected.

Что такое «модификаторы видимости»

В ООП модификаторы видимости применяются для контроля уровня доступа к элементам класса: полям, методам, внутренним классам. В большинстве языков ООП имеется три типа модификаторов видимости: public, private и protected.

Модификатором public обозначается, что элемент класса доступен в программе из любого кода, в котором имеется экземпляр класса или ссылка на статический элемент.

Модификатором private обозначается, что элемент класса доступен только из того же класса, то есть ни из кода вне класса, ни даже из подклассов.

Модификатором protected обозначается, что элемент класса доступен из того же класса и его подклассов, но не из кода вне иерархии классов. О подклассах поговорим в разделе наследования.

Примеры инкапсуляции 

class User {
private var name: String = ""

fun getName(): String {
return name
}

fun setName(userName: String) {
name = userName
}
}

В этом примере у класса User имеется закрытое свойство name. Помечая свойство как private, мы запрещаем прямой доступ к нему извне класса, инкапсулируя данные.

Для доступа к name или его изменения в классе имеется два открытых метода: getName() и setName(). В первом возвращается значение свойства name, во втором свойство name меняется. С помощью этих методов мы контролируем доступ и изменение свойства name, инкапсулируя методы.

Рассмотрим другой пример:

data class Item(val id: Long, val price: Double, val name: String, val stockCount: Int, val quantity: Int )

class ShoppingCart(private val items: MutableList<Item>) {
private var totalPrice: Double = 0.0

fun addItem(item: Item): Boolean {
if (item.quantity > item.stockCount) {
return false // Товар в корзину не добавляется
}

items.add(item)
totalPrice += (item.price * item.quantity)
return true
}

fun removeItem(item: Item) {
items.remove(item)
totalPrice -= (item.price * item.quantity)
}

fun getTotalPrice(): Double {
if (isTotalPriceReachedFreeCargoThreshold()) {
return totalPrice
}

return totalPrice + getCargoPrice()
}

private fun getCargoPrice(): Double = 33.0

private fun isTotalPriceReachedFreeCargoThreshold(): Boolean {
return totalPrice >= 100
}

}

Здесь классом ShoppingCart инкапсулируется список объектов Item. В закрытом свойстве items этого класса хранится список товаров, в закрытом свойстве totalPrice  —  общая цена всех товаров в корзине.

В методе addItem(item: Item): Boolean в корзину добавляется товар Item. Если запрошенное количество больше имеющихся запасов, в методе возвращается false, то есть товар в корзину не добавлен. А если не больше, то товар в нее добавляется, свойство totalPrice обновляется. Так мы инкапсулировали товары корзины.

В методе getTotalPrice() вычисляется общая стоимость всех товаров в корзине, в том числе стоимость доставки. Если общая стоимость корзины не меньше порогового значения бесплатной доставки (здесь 100), в методе возвращается только свойство totalPrice. Если меньше, к свойству totalPrice добавляется стоимость доставки, и возвращается результат.

В классе Shopping Cart инкапсулируются все управление и логика, связанные с товарами корзины, любые изменения этих процессов извне исключены.

Преимущества инкапсуляции

Во-первых, во избежание случайного изменения или неправильного использования внутренняя работа класса ShoppingCart скрыта от внешнего кода. Надежность и стабильность кодовой базы от этого повышается.

Во-вторых, упрощается сопровождение и обновление кода, ведь изменения вносятся во внутреннюю реализацию класса ShoppingCart, не затрагивая внешний код, в котором он используется. За счет этого улучшается модульность, сокращается время разработки.

В-третьих, повышается безопасность кода, поскольку конфиденциальные данные или логика сохраняются в тайне, недоступными для неавторизованного кода. Это особенно важно при работе с платежными системами или другими конфиденциальными данными.


Что такое «наследование» 

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

Наследование построено на идее отношения “is-a” («это»), то есть подкласс  —  это тип своего суперкласса. Например, автомобиль  —  это тип транспортных средств, поэтому создаваемый подкласс Car («автомобиль»), наследуется от суперкласса Vehicle («транспортное средство»).

Пример наследования 

Рассмотрим онлайн-платформу, на которой продают разные товары: электронику, одежду, бытовую технику. У каждого типа товара имеются общие атрибуты: название, описание и цена  —  и специфические: размер, цвет, материал.

Реализуем это в Kotlin, используя наследование, и создадим с помощью ключевого слова open родительский класс Product с общими атрибутами. На других языках в open обычно нет необходимости:

open class Product(val name: String, val description: String, val price: Double) {
// общие для всех товаров методы и свойства
}

Затем создаем дочерние классы для каждого типа товара, которым наследуется класс Product с помощью : и добавляются специфические атрибуты. В других языках обычно используется ключевое слово extends:

class Electronics(val brand: String, val model: String, name: String, description: String, price: Double,) 
: Product(name, description, price) {
// дополнительные методы и свойства для электроники
}

class Clothing(val size: String, val color: String, name: String, description: String, price: Double,)
: Product(name, description, price) {
// дополнительные методы и свойства для одежды
}

Каждым дочерним классом от родительского Product наследуются общие и добавляются собственные специфические атрибуты, так избегается дублирование кода для общих атрибутов в разных типах товаров:

// Использование классов и переменных
fun main() {
val computer = Electronics(
brand = "Apple",model = "M2 Air",
name = "Apple Computer", description = "", price = 1999.99
)

val tShirt = Clothing(
size = "S", color = "Green",
name = "Basic T-Shirt", description = "", price = 29.99
)

println("Common variable ${computer.name} Specific variable ${computer.model}")
println("Common variable ${tShirt.name} Specific variable ${tShirt.size}")

}

Преимущества наследования

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

Благодаря наследованию создается модульный код. Изолируя общие свойства и методы в родительском классе, код проще сопровождать и обновлять. За счет этого предотвращаются ошибки, повышается удобство восприятия кода, а также создается иерархия классов, организованных по их общим свойствам и поведению.

С наследованием становится возможным полиморфизм, когда объекты дочерних классов считаются объектами родительского класса. От этого гибче структура кода и проще написание дженериков.


Что такое «полиморфизм»

Полиморфизм  —  это способность объектов принимать разные формы, так что у нескольких различных типов одно имя или интерфейс.

В ООП различают два вида полиморфизма: времени компиляции, или перегрузка методов, и времени выполнения, или переопределение методов.

Полиморфизм времени компиляции (перегрузка)

Полиморфизм времени компиляции достигается при наличии в классе нескольких методов с одинаковым именем, но разными параметрами.

fun blablaFun() {}
fun blablaFun(number: Int) {}
fun blablaFun(number: Double) {}
fun blablaFun(number: Int, number2: Float) {}

Во время компиляции правильный вызываемый метод определяется по количеству и типу переданных в него аргументов.

Пример полиморфизма времени компиляции (перегрузки) 

fun showDialog(context: Context, title: String, message: String) {
val builder = AlertDialog.Builder(context)
builder.setTitle(title)
builder.setMessage(message)
builder.show()
}

fun showDialog(
context: Context, title: String, message: String,
positiveText: String,
negativeText: String,
onPositiveClicked: () -> Unit,
onNegativeClicked: () -> Unit
) {
val builder = AlertDialog.Builder(context)
builder.setTitle(title)
builder.setMessage(message)
builder.setPositiveButton(positiveText) { dialog, which ->
onPositiveClicked()
}
builder.setNegativeButton(negativeText) { dialog, which ->
onNegativeClicked()
}
builder.show()
}

Первой функцией принимаются параметры context, title, message и создается AlertDialog с этими title и message, который отображается с помощью метода builder.show().

Второй функцией принимаются дополнительные параметры positiveText, negativeText, onPositiveClicked, onNegativeClicked и создается AlertDialog посложнее с кнопками positiveText и negativeText и прослушивателями нажатий onPositiveClicked и onNegativeClicked. Когда пользователь нажимает кнопку, соответствующий прослушиватель выполняется.

У второй функции параметров больше, это перегруженная версия первой. Когда showDialog вызывается с тремя параметрами, выполняется первая, когда с дополнительными  —  вторая. При таком подходе  —  за счет разных версий одной функции с разными параметрами  —  создаются более гибкие и повторно используемые функции.

Если использовать именованные и аргументы Kotlin по умолчанию, полиморфизм обеспечивается без написания множества функций.

Полиморфизм времени выполнения (переопределение)

Полиморфизм времени выполнения достигается при наличии в подклассе метода с той же сигнатурой  —  то есть именем и параметрами,  —  что и метод в его суперклассе. Во время выполнения правильный вызываемый метод определяется по фактическому объекту, на который ссылаются, а не только по типу ссылки:

open class PaymentMethod {
open val type: String = ""

open fun pay(amount: Double) {
println("Payment completed with the default payment method.")
}
}

class CreditCard : PaymentMethod() {
override val type: String = "CreditCard"
override fun pay(amount: Double) {
// код для обработки платежа кредитной карты находится здесь
// вводятся номер карты, имя, CVV и т. д., затем переходят к оформлению и оплате заказа
println("Payment completed with credit card.")
}
}

class BankTransfer : PaymentMethod() {
override val type: String = "BankTransfer"
override fun pay(amount: Double) {
// здесь код для обработки оплаты по банковскому переводу
// показывается номер IBAN
println("Payment completed with bank transfer.")
}
}

class Order(private val paymentMethod: PaymentMethod) {
fun processPayment(amount: Double) {
paymentMethod.pay(amount)
}
}

В классе Order этого примера, чтобы обработать платеж, методом processPayment принимается сумма и в объекте PaymentMethod вызывается метод pay.

У класса PaymentMethod имеется два подкласса: CreditCard и BankTransfer. Ими переопределяется метод pay соответствующей реализации обработки платежа.

При оформлении и оплате заказа, когда пользователь выбирает способ оплаты, создается экземпляр соответствующего  —  CreditCard или BankTransfer  —  подкласса и передается в объект Order.

Когда вызывается метод processPayment, им вызывается метод pay в переданном объекте способа оплаты, в котором выполняется соответствующая реализация метода pay, определяемая по типу объекта во время выполнения. Когда метод pay вызывается в объекте типа CreditCard, выполняется реализация класса CreditCard, когда в объекте типа BankTransfer  —  реализация класса BankTransfer.


Что такое «абстракция»

Абстракция  —  это упрощенное представление сложных систем или идей с целью акцентирования внимания на существенных характеристиках объекта или системы за счет скрытия излишней сложности и деталей.

В ООП абстракция достигается применением абстрактных классов и интерфейсов. Абстрактный класс не инстанцируется, он  —  макет для других классов и может содержать абстрактные методы, то есть методы без реализации. Последние должны переопределяться подклассом, которым абстрактный класс расширяется.

С другой стороны, интерфейсы  —  это коллекции абстрактных методов, которыми определяется набор поведений. Последние должны реализовываться классом, а тот  —  предоставлять реализацию для всех своих методов.

Пример абстракции

abstract class Product(val name: String, val price: Double) {
// Абстрактный метод для расчета общей стоимости товара
abstract fun calculateTotalCost(quantity: Int): Double
}

class Book(name: String, price: Double, val author: String) : Product(name, price) {
// Реализация абстрактного метода для книг
override fun calculateTotalCost(quantity: Int): Double {
return price * quantity
}
}

class Clothing(name: String, price: Double, val size: String) : Product(name, price) {
// Реализация абстрактного метода для футболок
override fun calculateTotalCost(quantity: Int): Double {
val basePrice = price * quantity
return if (size == "XL") {
basePrice + 9.99 // Футболки XL стоят дополнительно 9,99 ₺
} else {
basePrice
}
}
}

Здесь в абстрактном классе Product представлен обобщенный товар. В этом классе имеются свойства name и price, а также абстрактный метод calculateTotalCost, в котором принимается количество и возвращается общая стоимость товара.

И имеются наследуемые от Product подклассы Book и Clothing, которыми благодаря наследованию расширяется класс Product и на основе специфических характеристик каждого товара реализуется их собственная версия метода calculateTotalCost.

Например, в классе Book общая стоимость рассчитывается простым перемножением цены и количества, а в классе Clothing к общей стоимости одежды размера XL добавляется 9,99 ₺.

Таким применением абстракции создается модульная, гибкая система для обработки различных типов товаров без переписывания кода для каждого из них. Чтобы для всех товаров в системе предоставить согласованный интерфейс, просто создаем новые подклассы Product и реализовываем их собственную версию метода calculateTotalCost, используя абстрактный класс Product.

Преимущества абстракции

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

За счет общего интерфейса для похожих классов абстракцией уменьшается дублирование кода в системе. В том же примере в классе Product предоставляется общий интерфейс для различных типов товаров: книг Book и одежды Clothing.

С абстракцией улучшается модульность, система разбивается на более управляемые меньшие части. В примере детали реализации товара инкапсулируются классом Product: так его проще изменять или заменять, не затрагивая другие части системы. Определением абстрактных классов и интерфейсов создается разделение обязанностей между различными компонентами системы  —  так облегчается сопровождение и внесение изменений в систему с течением времени.

Абстракцией повышается гибкость системы, разные реализации одного интерфейса используются взаимозаменяемо. В примере интерфейс Product реализуется книгами Book и одеждой Clothing, не затрагивая остальную систему.

Абстракцией облегчается тестирование, интерфейс и детали реализации четко разделены. В примере интерфейс Product тестируется независимо от реализаций книг Book и одежды Clothing.


Заключение

Большинство из нас представляет архитекторов проектирующими и строящими дома или планирующими здание компании. На самом деле цель архитектора  —  решить проблему. Хороший архитектор проектирует здание под потребности клиентов, сочетая функциональность и эстетику. При разработке ПО подход аналогичный: в ООП код разрабатывают ради большей удовлетворенности пользователей, делая его удобнее для восприятия, более модульным и управляемым.

Читайте также:

Читайте нас в TelegramVK и Дзен


Перевод статьи Cengiz Toru: Introduction of Object-Oriented Programming Concepts with Real-World Scenarios

Предыдущая статьяБудущее CSS: новейшие возможности языка декорирования в 2023 году
Следующая статьяКак автоматизировать удаление ненужных файлов с помощью Python