Эта статья предназначена в основном для новичках в программировании. Здесь будет краткий ввод в то, что же такое ООП, зачем оно нужно, какие проблемы решает и как с помощью него продуктивность как целой команды так и отдельных программистов может многократно возрасти.
Зачем же нужна ещё одна статья, когда на эту тему их сделаны сотни? Я придерживаюсь идеи, что нельзя обсуждать идею наследования без понимания того, что такое полиморфизм, а идею полиморфизма без понимания того, что такое интерфейсы. Многие статьи, что я читал, путали меня в самом начале карьеры. Надеюсь эта работа поможет многим ознакомиться с этим подходом.
Необходимые знания
Читатель должен быть знаком с работой циклов, функций и переменных. Желательно в строго-типизированных языках, где явно задается типизация данных. То есть тем, кто изучает python и javascript, некоторые вещи могут показаться трудными или неестественными.
Разрушение некоторых заблуждений
ООП расшифровывается как объектно-ориентированное программирование. Многие думают, что ООП это привелегии некоторых отдельных языков, что в паскале и чистом Си его нет, а в джазе и шарме оно повсюду. На самом же деле язык не так важен. ООП - подход к написанию кода и его можно реализовать на любом языке. В частности концепции ООП ярко выражены в исходниках ядра Linux, что писался на голом Си. Другое дело, что есть объектно-ориентированные языки. Они так называются потому что в них есть встроенная удобная поддержка ООП.
Причем в разных языках поддержка может быть разной. Не стоит думать, что слово "class" - обязательно будет в любом объектно-ориентированном ЯП. В Go используются структуры и отдельно дописываются функции, что с ними работают. В Javascript долгое время не было классов вообще, использовались функции, прототипы, замыкание для реализации ООП. Другими словами нет каких-то догм того, как это должно работать. И никто не может гарантировать, что в языках следующего поколения ООП будет таким же как и сейчас (в плане написания кода), но концепции и основные принципы сохраняются во всех языках и этого факта не изменить. Поэтому эта статья призвана изучить теоретическую составляющую этого подхода.
Выбор языка программирования
Как уже сказано в предыдущей части, выбор ЯП не особо важен для демонстрационной статьи. Однако выбирать java, C# - издевательство над читателем, где код с первых строк тычет нам в лицо классами и функциями, сложными словами на подобие static, public и т.д. На Python, js и других языках такого варианта было бы трудно показать некоторые преимущества ООП. Нам нужен строго-типизированный язык, где тип переменных предсказуем.
В статье для ознакомления я использую Котлин. Основным преимуществом языка является возможность комбинировать процедурный и ООП подходы. Такими же преимуществами обладает и c++, но я решил его не использовать ввиду более сложной работы с интерфейсами, что всплывут в этой статье.
Нет причины переживать, если Вы еще не знаете этот язык. Его код легко читается, если у вас есть минимальный опыт работы с другими строго-типизированными языками. К тому же некоторые моменты в примерах кода я постараюсь объяснять.
Приход к идее ООП
Представим, у нас есть желание написать программу. Идея такова: мы получаем погоду с сайта https://openweathermap.org. Параллельно узнаем погоду и с бота в телеграмме, на которого мы подписались. Получив цифры, мы высылаем на наш email примерно следующее сообщение:
Привет, по разным данным сегодня на улице будет 20 или 22 градуса. Диапазон - 2 градуса. Удачного дня!
Код программы имеет следующую структуру:
// Main.kt
val url = "https://openweathermap.org"
val botUrl = "some-telegram-url"
var webData = 0
var botData = 0
var difference = 0
val email = "my-email@yandex.ru"
val emailPass = "********"
fun getDataFromWeb() {
// using url, webData
}
fun getDataFromBot() {
// using botUrl, botData
}
fun getDifference() {
// using webData, botData, difference
}
fun sendEmail() {
// using email, emailPass
}
fun main(args: Array<String>) {
// some code
}
Это простой шаблон кода, с которым легко работать до те пор, пока он маленький. Можно добавить много фишек. Это может быть рассылка не только на почту, но и в вк, телеграм. Можно давать рекомендации того, как нам одеться, основываясь на температуре воздуха. Можно поставить все это выполняться по расписанию. Например каждый день в 7 утра.
С каждой новой идеей, что мы воплотим в коде, он становится только сложнее. На 100 функциях и 500 переменных он становится трудно читаемым. А на 1000 функциях его поддерживать невозможно. По ряду причин:
Шанс ошибиться в выборе нужной переменной достаточно велик. А если будем использовать не ту переменную, то все легко может сломаться.
Легко можно привести систему в неправильное состояние, ведь у нас есть потенциальный доступ ко всем данным на постоянной основе. Например, переменная difference должна хранить разность показателей температуры на улице. Но ничто нам не мешает её сделать какой угодно другой напрямую. Система становится небезопасной, её стабильность под вопросом. Такие вещи нельзя давать на откуп компетентности программиста, что пишет код.
Эти проблемы легко решить, разделив код на отдельные файлики. Для этого осмыслим то, что код делает принципе.
Он взаимодействует с сервисом для погоды, с телеграммной ботом и с почтой. Значит потенциально мы можем разделить код на 3 отдельные сущности. Поместим всё, что относится к почте, в 1 файл, всё, что связано с телеграммом, во второй и всё, что связано с openweathermap.org, в третий. Пока сделаем это в голове, далее мы рассмотрим это на одном конкретном примере.
Теперь каждый файл имеет функции, связанные между собой общей идеей, и данные, с которыми эти функции работают. Не обращайте внимание, что в каждом файле всего по одной функции, как никак это сильно упрощенная версия. В действительности запросы к сервисам нередко описываются десятком функций.
Жесткая связка "данные-функции" и есть концепция ООП.
Классы. Отличия классов от объектов.
Объектно-ориентированное программирование - подход к написанию кода, при котором активно используются объекты. Каждый объект представляет собой осмысленную, самостоятельную сущность (пример таких приведен ранее). В объекте уже определено то, какими данными он владеет и как может с ними взаимодействовать. В идеале это должна быть маленькая изолированная от других сущностей в коде система. И при создании такой системы мы указываем то, как с ней можно работать извне, то есть задаем интерфейс сущности. Попробуем разобрать это на уже существующем примере. Как помним, у нас появилась сущность для отправки сообщений по email. Посмотрим на то, как она выглядит сейчас. Как помним, мы поместили ее в отдельный файл.
// EmailSender.kt
val email = "my-email@yandex.ru"
val emailPass = "********"
fun sendEmail(to: String, mailText: String) {
// using email, emailPass
}
Котлин имеет встроенную поддержку ООП. Для лучшего понимания обернем код выше в следующий:
// EmailSender.kt
class EmailSender {
private val email = "my-email@yandex.ru"
private val emailPass = "********"
public fun sendMessage(to: String, mailText: String) {
// using email, emailPass
}
}
Обратим внимание, что мы переименовали функцию sendEmail в sendMessage. Потому что если функция принадлежит классу EmailSender, то и так понятно, что она будет отправлять email. Message - более общее название, лишенное излишней тавтологии.
Ключевое слово class в коде выше создает связку, что объединяет некоторые данные и функции, что с этими данными работают.
Далее в статье мы будем придерживаться следующей терминологии:
Функция внутри класса называется методом.
Переменная внутри класса называется полем.
Прежде всего нам следует понять разницу между классом и объектом. Класс - сущность, что порождает объект. Класс, что мы создали имеет структуру. Ему известно, какими полями и методами он обладает. И на основе такого класса будут появляться объекты. У каждого объекта могут быть свои собственные значения полей, но структура будет такой же. Методы не изменятся, как и количество и тип полей. В коде мы как правило работаем с объектами, а не с классами.
Зачем нужны такие сложности? У этого есть огромное преимущество. Мы можем очень эффективно повторно использовать один и тот же код. Для демонстрации чуть поменяем наш класс на следущий.
// EmailSender.kt
class EmailSender(private val email: String,
private val emailPass: String) {
public fun sendMessage(to: String, mailText: String) {
// using email, emailPass
}
}
На самом деле почти ничего не изменилось, просто теперь мы используем поля email и emailPass в скобках класса, будто это аргументы функции. То есть мы не жестко задаем значения этих полей в коде, а получаем их извне. И теперь можем использовать код следующим образом:
// Main.kt
fun main(args: Array<String>) {
val emailSender = EmailSender("from@mail.ru", "******")
emailSender.sendMessage("to@mail.ru", "hello!")
}
Здесь мы берем класс EmailSender, кладем в него конкретные значения полей и получаем на выходе объект emailSender. У него есть и конкретные поля и методы, что с ними могут работать. В частности sendMessage. Но мы не ограничены одним объектом. У одного класса могут быть сотни объектов, что мы можем инициализировать и использовать в коде. У всех них будет тип EmailSender, в честь названия класса, от которого они произошли. Вот пример использования
// Main.kt
fun main(args: Array<String>) {
val arr = arrayOf(
EmailSender("from1@mail.ru", "******1"),
EmailSender("from2@mail.ru", "******2"),
EmailSender("from3@mail.ru", "******3")
)
arr.forEach {
it.sendMessage("to@mail.ru", "hello!")
}
}
Здесь мы создали массив сендеров с разными логинами и паролями. Затем прошлись по каждому из них циклом, послав сообщение одному и тому же адресату. Как видите, это несложно. Ну а теперь поговорим о парадигмах, что есть в ООП.
Знакомство с парадигмами ООП
Ранее было сказано, что у каждого класса есть интерфейс. Интерфейс что в коде что в жизни (интерфейс сайта, интерфейс техники) - одно и то же. Набор необходимых методов, через которые можно взаимодействовать с системой. А то есть с порожденными объектами.
Сейчас у класса EmailSender только 1 метод - sendMessage. И она представляет весь интерфейс класса. У нас больше нет доступа ни к чему. Доступ мы можем определять ключевыми словами public и private, что указаны перед полями и перед методами в классе. Извне мы никак не узнаем и не поменяем ни пароль ни логин, как бы ни старались.
Этим мы гарантируем, что объект будет всегда находиться в правильном состоянии на протяжении всей его работы. Ведь если мы поменяем, например, пароль, то класс уже не сможет подключиться к сервису по работе с почтой и не сможет отправлять сообщения.
Отсюда можно узнать ещё некоторые особенности ООП. Ограничив способ взаимодействия нашего класса с другим кодом, мы в той или иной степени можем гарантировать, что объект будет находиться в стабильном состоянии. Это свойство носит название инкапсуляция и она является одной из парадигм объектно-ориентированного программирования. Теперь поподробнее попытаемся понять, что такое инкапсуляция.
Инкапсуляция - парадигма, отвечающая за связку "данные-функции" и за сокрытие некоторых компонентов класса извне. Это набор мер, необходимый для того, чтобы объект находился в правильном состоянии. Именно наличие инкапсуляции позволяет строить сложные и при этом стабильные системы.
Инкапсуляция - это основа всего ООП, но далеко не всё, что этот подход из себя представляет.
Познакомимся теперь с полиморфизмом - это второй краеугольный камень ООП. Если дословно перевести это слово с английского, то будет что-то вроде "имеющий несколько форм".
Для начала давайте подумаем что вообще может делать функция снизу:
fun sendMessage(to: String, mailText: String)
Здесь указана только сигнатура метода. Если вы не знакомы с этим словом - это объединение названия метода, типов аргументов и типа возвращаемого значения. В данном случае ничего не возвращается.
Почему мы должны ориентироваться только по сигнатуре, а не по реализации? Потому что сигнатура должна в целом описывать то, что должна делать функция, это shortcut для какого-то набора команд. Название должно быть осмысленным и в целом описывать то что будет выполняться внутри функции. Всякий раз, когда мы читаем код, мы читаем только названия функций, что выполняются. Но, разумеется, мы не можем знать наверняка, что происходит внутри, лишь имеем предположение.
Сигнатура функции должна в достаточной мере описывать то, что она делает.
Представим, что у нас появилось желание высылать уведомление о погоде не только на почту, но и в вк. Создадим класс VkSender
// VkSender.kt
class VkSender(private val login: String,
private val password: String) {
public fun sendMessage(to: String, mailText: String){
// using login, password
}
}
Как мы видим этот класс мало чем отличается от класса EmailSender. Они выполняют схожую функцию. Класс существует для того, чтобы отправлять сообщения. Куда - уже не так важно. Этот класс может так же общаться в другим кодом, как и EmailSender, т.к. у него точно такой же интерфейс (кто не помнит - набор методов, через которые с этим классом можно взаимодействовать извне).
Имеет ли этот код другую реализацию работы внутри? Да, конечно. Работа с vk api отличается от работы с почтовыми сервисами. Где-то авторизация по паролю. Где-то нужен токен - длинное уникальное значение (например так в телеграме).
Но в любом случае идея функции та же - отправлять сообщения. Здесь и всплывает идея полиморфизма. Неважно как именно метод реализован (какая его форма), главное - что он выполняет в целом. А это указано в сигнатуре метода.
Введем общее понятие интерфейса в ООП. Интерфейс - набор внешних методов какого-либо класса с точной сигнатурой. У каждого класса есть свой интерфейс. При этом класс может реализовывать другие интерфейсы, если интерфейс класса имеет все методы, указанные в других интерфейсах. Одновременно класс может реализовывать хоть десяток интерфейсов и при этом иметь собственный, что включает в себя всё реализуемое. Разные классы могут реализовывать одни и те же интерфейсы.
Учитывая что по сигнатуре метода можно понять что она делает (а так должно быть всегда), то интерфейс в целом определяет то, какие возможности есть у класса, что его реализует. При этом конкретная реализация этих возможностей нас совершенно не интересует. Если привести аналогию: не важно крикните вы соседу, кинете камень в его окно или напишете ему смс - Вы все равно способны достучаться до него. В данном случае способ не важен, имеет значение только результат.
Таким образом VkSender и EmailSender имеют один и тот же интерфейс. Теперь мы можем взаимодействовать с этими классами не как с отдельными сложными сущностями, а как со схожими элементами. Пусть у нас будет интерфейс ISender. Тогда с VkSender и с EmailSender можно общаться одинаково, через единственный метод sendMessage.
interface ISender {
public fun sendMessage(to: String, text: String)
}
// EmailSender.kt
class EmailSender(private val email: String,
private val emailPass: String): ISender {
public override fun sendMessage(to: String, mailText: String){
// using email, emailPass
}
}
// VkSender.kt
class VkSender(private val login: String,
private val password: String): ISender {
public override fun sendMessage(to: String, vkText: String){
// using login, password
}
}
// Main.kt
fun main(args: Array<String>) {
val someSender: ISender = EmailSender("", "") // явный интерфейс ISender
val arr = arrayOf(
EmailSender("from1@mail.ru", "******1"),
VkSender("login", "pass")
)
arr.forEach {
// понимается котлином какой это интерфейс и так, берется ISender
// просто я это не указываю
it.sendMessage("Miro", "hello")
}
}
В коде сверху предполагаю, что он по слову "Miro" VkSender найдет его аккаунт, а в EmailSender - почту.
Теперь мы, например, можем хранить все наши отправители сообщений (а их может быть не 2 и не 10) в одном общем массиве. При получении о результатах погоды мы можем пробежавшись по циклу быстро разослать сообщения на всевозможные платформы. Это сильно облегчает понимание кода. Подсознательно мы начинаем понимать, что это не куча непонятных сложных классов, а просто разная реализация схожего функционала. Причем реализация, что их различает, нас не волнует. Обращаемся с классами мы одинаково. Мы абстрагируемся до простого слова ISender и метода sendMessage с полной сигнатурой, что в полной мере обозначает, что делает этот класс. Это и приводит нас к идее абстракции. Это парадигма не превносит ничего конкретно нового, просто является логическим следствием использования полиморфизма в коде.
А также можем поразмыслить над следующем. Если классы реализуют один и тот же интерфейс, могут вызываться через один и тот же тип переменной, могут находиться в одном и том же массиве, то значит для нас эти классы могут быть заменены друг другом, в зависимости от наших нужд. Допустим мы переписываем проект под новую базу данных, используем другой формат хранения и передачи данных (xml вместо json), все парсеры, все обработчики баз данных явно имеют один и тот же интерфейс. И стоит нам поменять в одном месте вызываемый класс - так меняется реализация какого-то процесса. Но суть остается та же. Не придется править десятки или сотни строчек кода, где вызываются методы класса, т.к. методы те же сами. Это гигантское преимущество разработчиков, что используют полиморфизм перед теми, кто работает без него.
Кроме того, если мы создаем какой-то новый класс, что пойдет, возможно, в замену предыдущему, то мы никогда не забудем какие-то методы из реализуемого интерфейса, потому что иначе код просто не скомпилируется. Так что и тут полиморфизм помогает избежать потенциальных ошибок.
Вернемся к основным парадигмам ООП.
Допустим у нас есть класс EmailSender. Но мы не хотим просто отсылать сообщение. Хотим приукрасить его смайликами, что будут отражать состояние погоды сейчас, ведь визуальная составляющая сообщения очень важна и на нее обращают внимание прежде всего. Впрочем неважно какая надстройка будет над классом, главное что дополняется его внутренний функционал. Создавать новый класс смысла нет, т.к. прописывать в другом месте всю работу с почтовым сервисом - лишняя проблема. Объектно-ориентированные языки программирования позволяют сделать надстройку над уже существующем классом. То есть создать класс, что скопирует весь функционал своего класса-родителя. При этом будет иметь возможность создавать свои уникальные функции.
// PrettyEmailSender.kt
class PrettyEmailSender(private val email: String,
private val emailPass: String): EmailSender(email, emailPass) {
private fun doItPretty(text: String): String{
// some code
return text
}
public override fun sendMessage(to: String, mailText: String){
val prettyText = doItPretty(mailText)
super.sendMessage(to, prettyText)
}
}
Здесь мы в фукнции sendMessage делаем отправляемый текст красивее, с помощью новой приватной функции doItPretty. Она не расширяет интерфейс, через который с классом можно работать снаружи, т.к. оттуда она не видна. Ну а дальше вызываем функцию sendMessage от класса-родителя с помощью ключевого слова super. То есть нам не приходится переписывать уже рабочий функционал. Мы просто немного его дополняем.
Для того, чтобы наследование сработало, нужно сделать класс-родитель открытым.
// EmailSender.kt
open class EmailSender(private val email: String,
private val emailPass: String): ISender {
public override fun sendMessage(to: String, mailText: String){
// using email, emailPass
}
}
Создание класса на основе уже существующего и возможность дополнить таковой дополнительными методами - отдельная парадигма ООП, называемая наследованием.
Наследование в общем плане не может нормально существовать без полиморфизма. Базовый и производный класс реализуют один и тот же интерфейс, т.к. производный класс имеет все методы базового. А значит с производными классами или с классами-потомками можно обращаться одинаково. И все их хранить в одном массиве, что порой очень полезно. Можно проворачивать и другие трюки благодаря полиморфизму, что мы рассмотрели ранее.
Благодаря полиморфизму можем использовать новый класс и как тип ISender, и как тип EmailSender, и как тип PrettyEmailSender. Смотря с какой стороны мы хотим посмотреть на этот класс. Если мы хотим добавить в PrettyEmailSender новый открытый функционал, дополнив его интерфейс, то имеет смысл хранить его в переменной, типо которой PrettyEmailSender. В противном случае, мы смотрим на класс с точки зрения другого интерфейсного взаимодействия и доступа к новым методам у нас не будет.
Если нового открытого функционала нового нет - можем всё хранить в типо ISender и не волноваться.
Вывод статьи
Мы познакомились с концепцией отделения разнотипных данных друг от друга, и объединения "данные-функции". Узнали что такое инкапсуляция, полиморфизм, наследование. Узнали в чем преимущество использования ООП по сравнению с более примитивными подходами. В частности это возможность заменяемости некоторого кода и гарантирование того, что система в целом или маленькая подсистема программы будет находиться в стабильном состоянии. А так же возможность многократно повторять один и тот же код.