Как создатель и руководитель курсов по C# я вижу, что часто у людей, начинающих изучать этот язык, принципы Объектно-Ориентированного Программирования вызывают затруднения в понимании. А так как один из лучших способов что-то понять, это посмотреть применение на примерах, то я решил написать статью с примерами принципов. Рекомендую найти какую-нибудь статью или книгу, где прочитать основную теорию, а в этой статье уже посмотреть примеры применения этой теории, чтобы понять её лучше.
На текущий момент есть различные точки зрения на то, сколько же в ООП всё-таки принципов и в этой статье мы будем считать, что этих принципов четыре: Инкапсуляция, Наследование, Полиморфизм и Абстракция. Примеры будут приведены на языке C#, однако, они очень простые, да и сама суть не зависит от языка, поэтому будет полезна всем начинающим изучать ООП программистам.
Инкапсуляция
Инкапсуляция в программировании является объединением данных и кода, работающего с этими данными, в большинстве случае это сводится к тому, чтобы не давать доступа к важным данным напрямую. Вместо этого мы создаем ограниченный набор методов, с помощью которых можно работать с нашими данными. Давайте рассмотрим несколько повседневных примеров, чтобы лучше понять это.
Пример: Уровень заряда батареи смартфона
public class Smartphone
{
private int _batteryLife;
// Метод заряжает батарею, но не имеет доступа к уровню заряда
public void Charge(int amount)
{
// Устанавливаем свои правила для работы с переменной
if (amount <= 0)
{
throw new ArgumentException("В метод для зарядки телефона передано значение меньше либо равное нулю")
}
_batteryLife += amount;
}
// Метод получает текущее значение, но не может его изменить
public int GetBatteryLife()
{
return _batteryLife;
}
}
Здесь _batteryLife
— это наши важные данные. У нас есть методы для зарядки и показа текущего значения, однако мы не даем доступ к самой переменной _batteryLife
, поэтому, например, пользователи класса не смогут убавить значение нашей переменной.
Пример: Избранные песни
Давайте представим музыкальное приложение, в котором можно добавлять или удалять песни из своего списка избранного.
public class MusicApp
{
private List<string> _favoriteSongs = new List<string>();
public void AddToFavorites(string songName)
{
if(!string.IsNullOrEmpty(songName) && !_favoriteSongs.Contains(songName))
{
_favoriteSongs.Add(songName);
}
}
public void RemoveFromFavorites(string songName)
{
_favoriteSongs.Remove(songName);
}
public List<string> GetFavorites()
{
return new List<string>(_favoriteSongs);
}
}
В этом примере инкапсулирован, то есть спрятан от доступа извне класса, список наших избранных песен (_favoriteSongs
). Мы предоставляем методы для управления списком, но не даем возможности работать со списком напрямую.
Пример: Переписка в соцсетях
В самом простом случае все, что мы можем сделать при общении в соцсети - отправить кому-то сообщение и прочитать сообщения, отправленные нам. В таком случае, чтобы не дать возможности другим программистам, которые будут использовать наш класс SocialMediaPlatform
случайно перезаписать наши сообщения, лучше будет предоставить им всего два метода - SendMessage
и ShowMyMessages
.
public class SocialMediaPlatform
{
private List<string> _privateMessages = new List<string>();
public void SendMessage(string message)
{
if(!string.IsNullOrEmpty(message))
{
_privateMessages.Add(message);
}
}
public List<string> ShowMyMessages()
{
return new List<string>(_privateMessages);
}
}
Как мы видим, сообщения инкапсулированы в списке _privateMessages
и код, использующий наш класс, не может делать с нашими сообщения ничего, кроме получения текущих и добавления новых.
Наследование
Наследование в какой-то степени похоже с биологическим наследованием. Вы получаете какие-то черты от своих родителей, но, в то же время, отличаетесь от них. Или представьте это как базовую модель гаджета, к которой затем добавляются улучшенные версии с дополнительными функциями. Давайте рассмотрим несколько примеров, чтобы лучше понять это.
Пример: Игры и дополнения
Предположим, вы купили игру в Стиме и через какое-то время у неё появляются два дополнения: HD version и HotA, которые основаны на оригинальной игре, но изменяют её части.
public class HeroesOfMightAndMagic3
{
public void Play()
{
Console.WriteLine("Запускаем классическую версию игры...");
}
}
public class HeroesOfMightAndMagic3Hd : HeroesOfMightAndMagic3
{
public void PlayHd()
{
Console.WriteLine("Запускаем игру в высоком разрешении (HD)...");
}
}
public class HeroesOfMightAndMagic3Hota : HeroesOfMightAndMagic3
{
public void PlayHota()
{
Console.WriteLine("Запускаем игру с двумя новыми городами...");
}
}
Классы HeroesOfMightAndMagic3Hd
и HeroesOfMightAndMagic3Hota
наследуют метод Play
для запуска оригинальной версии игры, но также каждый добавляет свои уникальные методы.
Пример: Версии смартфона
Рассмотрим смартфон, у которого есть базовая модель и есть версия Pro, которая наследует все базовые функции, плюс, добавляет некоторые продвинутые.
public class BasicSmartphone
{
public void Call()
{
Console.WriteLine("Совершаем звонок...");
}
}
public class ProSmartphone : BasicSmartphone
{
public void VideoCall()
{
Console.WriteLine("Совершаем видеозвонок...");
}
}
ProSmartphone
может звонить так же, как и BasicSmartphone
, но также имеет дополнительную функцию видеозвонка.
Пример: Онлайн кинотеатр
Онлайн кинотеатры часто предоставляют различные подписки для своих пользователей. Рассмотрим пример, где у такого кинотеатра есть базовый тариф и премиальный тариф, который предлагает все основные функции плюс эксклюзивный контент.
public class BasePlan
{
public void StreamStandardContent()
{
Console.WriteLine("Показываем контент базового плана...");
}
}
public class PremiumPlan : BasePlan
{
public void StreamExclusiveShows()
{
Console.WriteLine("Показываем контент премиального плана...");
}
}
PremiumPlan
предлагает все, что и BasePlan
, но также добавляет и новый, эксклюзивный контент.
Полиморфизм
Полиморфизм немного напоминает универсальный пульт дистанционного управления, который может адаптироваться для управления различными устройствами. В программировании это означает, что один интерфейс может использоваться для управления разными методами, давая разные результаты в зависимости от контекста.
Пример: Музыкальный плеер
Представьте себе музыкальный плеер, который может воспроизводить разные аудиоформаты, такие как mp3, wav и flac. Для каждого формата требуется свой метод воспроизведения, однако, вместо создания методов Play
, PlayMp3
, PlayWav
, PlayFlac
, правильнее будет использовать общий метод Play
.
public class MusicPlayer
{
public virtual void Play()
{
Console.WriteLine("Воспроизводим аудио в стандартном формате...");
}
}
public class Mp3Player : MusicPlayer
{
public override void Play()
{
Console.WriteLine("Воспроизводим mp3...");
}
}
public class WavPlayer : MusicPlayer
{
public override void Play()
{
Console.WriteLine("Воспроизводим wav...");
}
}
public class FlacPlayer : MusicPlayer
{
public override void Play()
{
Console.WriteLine("Воспроизводим flac...");
}
}
В этом примере независимо от аудиоформата у нас есть один постоянный метод Play
, выполнение которого меняется в зависимости от формата.
Пример: Виртуальный ассистент
Подумайте о виртуальном ассистенте, который работает на смартфоне, смарт-часах и смарт-колонке. Вы можете попросить все эти устройства "Включить свет", но ответ может быть адаптирован в зависимости от устройства.
public class VirtualAssistant
{
public virtual void ExecuteCommand(string command)
{
Show($"Выполняю команду {command}...");
}
}
public class SmartwatchAssistant : VirtualAssistant
{
public override void ExecuteCommand(string command)
{
ShowOnSmallScreen($"Выполняю команду {command}...");
}
}
public class SmartSpeakerAssistant : VirtualAssistant
{
public override void ExecuteCommand(string command)
{
Say($"Выполняю команду {command}...");
}
}
Команда одинакова, но ее выполнение адаптируется в зависимости от контекста устройства. В базовом случае мы просто выводим сообщение о том, что команда выполняется, на экран (Show
). У умных часов экран маленький, поэтому нам нужен особый способ вывода сообщения на экран (ShowOnSmallScreen
), а у умной колонки вообще может не быть экрана, поэтому сообщение лучше озвучить голосом (Say
).
Пример: Потоковое видео
Рассмотрим платформу потокового видео, которая изменяет свое качество воспроизведения в зависимости от скорости интернета пользователя: HD, SD или 4K.
public class VideoStreamer
{
public virtual void Stream()
{
Console.WriteLine("Показываем в обычном качестве...");
}
}
public class HDStreamer : VideoStreamer
{
public override void Stream()
{
Console.WriteLine("Показываем в HD качестве...");
}
}
public class SDStreamer : VideoStreamer
{
public override void Stream()
{
Console.WriteLine("Показываем в SD качестве...");
}
}
public class FourKStreamer : VideoStreamer
{
public override void Stream()
{
Console.WriteLine("Показываем в 4K качестве...");
}
}
Независимо от качества интернета, пользователь просто запускает метод Stream, а платформа корректирует качество трансляции сама.
Абстракция
Абстракция похожа на использование умного устройства, не зная его сложной схемы. Например, чтобы переключить канал на телевизоре, мы просто нажимаем на кнопку на пульте, как кодируется пультом нажатие на кнопку, передается на телевизор и декодируется нам не важно. Важно чтобы канал переключился, а не тонкости радиотехники. Вот и в программировании абстракция означает предоставление основных функций без погружения в детали.
Пример: Автомобиль
Чтобы управлять автомобилем, нам в базовом случае достаточно знать о том, где находится руль, педаль тормоза и газа (да-да, и педаль сцепления для механики). То есть чтобы ехать нам совсем не нужно понимать тонкости работы двигателя, передачи крутящего момента, как устроен гидро или электроусилитель руля. Мы просто нажимаем на газ и машина едет, крутим руль и она поворачивает. Это и есть абстракция.
public abstract class Car
{
public void Accelerate()
{
Console.WriteLine("Разгоняемся...");
}
public void Brake()
{
Console.WriteLine("Тормозим...");
}
// Абстрактный метод запуска, различающийся для разных двигателей
public abstract void TurnOnEngine();
}
public class ElectricCar : Car
{
public override void TurnOnEngine()
{
Console.WriteLine("Запускаем электрический двигатель...");
}
}
public class DieselCar : Car
{
public override void TurnOnEngine()
{
Console.WriteLine("Запускаем дизельный двигатель...");
}
}
Независимо от типа автомобиля, мы запускаем двигатель нажатием на кнопку Start, не обращая внимания на то, что на самом деле процесс под капотом различается.
Пример: Погода
Что мы делаем, чтобы узнать прогноз погоды на сегодня? Просто открываем приложение на телефоне и оно показывает нам погоду. Как оно собирает для этого данные, как их обрабатывает, все это скрыто от нас.
public abstract class WeatherApp
{
public void DisplayForecast()
{
Console.WriteLine("Показываем текущий прогноз погоды...");
}
// Абстрактный метод получения данных, различающийся для текущего способа связи
public abstract void GetWeatherData();
}
public class WifiWeatherApp : WeatherApp
{
public override void GetWeatherData()
{
Console.WriteLine("Запрашиваем данные по WiFi...");
}
}
public class MobileWeatherApp : WeatherApp
{
public override void GetWeatherData()
{
Console.WriteLine("Запрашиваем данные по мобильной сети...");
}
}
Мы просто видим прогноз погоды на экране, хотя способ его получения может отличаться.
Пример: Кофемашина
Чтобы приготовить кофе в кофемашине мы заливаем воду, засыпаем кофейные зерна и выбираем тип кофе. Как дальше кофемашина заваривает его, скрыто от нас.
public abstract class CoffeeMachine
{
public void PourWater()
{
Console.WriteLine("Заливаем воду...");
}
public void AddBeans()
{
Console.WriteLine("Засыпаем зерна...");
}
// Абстрактный метод, специфичный для каждой кофемашины
public abstract void BrewCoffee();
}
public class EspressoMachine : CoffeeMachine
{
public override void BrewCoffee()
{
Console.WriteLine("Варим с использованием пара под высоким давлением...");
}
}
public class DripCoffeeMachine : CoffeeMachine
{
public override void BrewCoffee()
{
Console.WriteLine("Пропускаем горячую воду через зерна...");
}
}
В обоих случаях мы в результате получаем кофе, но метод заваривания (абстрагированный процесс) различается между машинами.
Заключение
Хоть эти концепции и могут казаться абстрактными, я очень надеюсь, что аналогии из реальной жизни и примеры кода помогают их понять. При этом, важно помнить, что ООП - это не серебрянная пуля и не высеченные в камне истины, которым всегда и везде нужно следовать. Ведь самое главное в нашей работе - это создание кода, который решает реальные проблемы, ну и желательно, чтобы его было удобно поддерживать и масштабировать.
Хочу также пригласить вас на бесплатный вебинар, где я расскажу про пять ключевых программных парадигм в C#: процедурное, объектно-ориентированное, функциональное, событийное и компонентно-ориентированное программирование. Мы рассмотрим основные характеристики каждого подхода, их преимущества и недостатки, а также примеры их применения на практике. Регистрируйтесь, будет интересно!
Комментарии (23)
dopusteam
29.09.2023 13:12+7У нас есть методы для зарядки и показа текущего значения, однако мы не даем доступ к самой переменной
_batteryLife
, поэтому, например, пользователи класса не смогут убавить значение нашей переменной// Метод заряжает батарею, но не имеет доступа к уровню заряда public void Charge(int amount) { _batteryLife += amount; }
Пока пользователь не передаст отрицательное значение в метод Charge
В этом примере инкапсулирован, то есть спрятан от доступа извне класса, список наших избранных песен (
_favoriteSongs
). Мы предоставляем методы для управления списком, но не даем возможности работать со списком напрямую.public List<string> GetFavorites() { return _favoriteSongs; }
Вы возвращаете наружу мутабельный тип List<string>, который без проблем можно изменить
Lexo Автор
29.09.2023 13:12Огромное спасибо, что не прошли мимо, поправил.
dopusteam
29.09.2023 13:12+1Вы не сделали лучше на самом деле.
Вы либо вообще не принимайте отрицательные числа, либо как то ошибкой реагируйте на них.
Не нужно просто игнорить их
И почитайте про ReadOnly коллекции например, сейчас не особо лучше стало
У вас, кстати, остался ещё один пример с точно такой же ошибкой с возвратом мутабелтной коллекции
Lexo Автор
29.09.2023 13:12+1Спасибо ещё раз, подправил примеры. Насчёт ReadOnly коллекций и мутабельности согласен и понимаю, что врядли кто-то будет возвращать каждый раз новую коллекцию, однако, очень хочется оставить материал и примеры максимально простыми и доступными. С той же целью заменил ToList на создание нового списка, думаю это проще должно восприниматься.
ImagineTables
29.09.2023 13:12+8Пока пользователь не передаст отрицательное значение в метод Charge
С моей точки зрения, это сущие пустяки по сравнению с главным: так писать вообще не надо. Может быть, кстати, что ошибка возникла именно поэтому — был бы реальный код для реальной задачи, над ним бы думали. Как я заметил, уже много лет среди преподавателей ООП царит какая-то эпидемия: его объясняют на кошечках, собачках и геометрических формах, вместо того, чтобы взять реальный пример — скажем, обёртку над WinAPI. В результате, идея, что классы должны отражать предметную область воспринимается как откровение и её раздувают аж до методологии доменного дизайна (кстати, внутренне противоречивой, ИМХО).
Зачем нужен класс Smartphone? Репрезентовать реальный смартфон с его зарядкой где-нибудь в API? Чтобы получить объект и узнать его характеристики, в т.ч. заряд? Но реальный смартфон не управляет своей зарядкой! И юзер не может дать смартфону команду: «Зарядись!». Юзер может только воткнуть провод, а дальше как пойдёт. Реальный Smartphone не хранит состояние заряда вообще, а лазит за ним в драйвер (скорее всего, открывая порт). И возвращает его в свойстве, а не методе, кстати говоря (я уж молчу, что декомпозиция, при которой уровень батарейки это свойство смартфона, весьма сомнительна). Хранить он может разве что кэш (если чтение дорогое). Почему не написать вот такой настоящий полезный класс для обучения?
Даже если мы пишем не API, а игру-симулятор, где надо заряжать много смартфонов, проблема останется та же самая: смартфон не контролирует свою зарядку. Спроектировано, то есть, неправильно. А потом эти люди приходят в промышленность и начинают писать, как их научили.
insighter
29.09.2023 13:12а ещё стоит добавить, что в бизнес-приложениях часто распространена анемичная доменная модель
dopusteam
29.09.2023 13:12+8public class HeroesOfMightAndMagic3 { public void Play() { Console.WriteLine("Запускаем классическую версию игры..."); } } public class HeroesOfMightAndMagic3Hd : HeroesOfMightAndMagic3 { public void Play() { Console.WriteLine("Запускаем игру в высоком разрешении (HD)..."); } } public class HeroesOfMightAndMagic3Hota : HeroesOfMightAndMagic3 { public void Play() { Console.WriteLine("Запускаем игру с двумя новыми городами..."); } }
Это плохой пример наследования, т.к. вы не переопределили методы в наследниках, а скрыли базовые методы.
На самом деле вся 'статья' крайне низкого качества
Lexo Автор
29.09.2023 13:12+1Спасибо за ещё один пример и за обратную связь. С вашей помощью качество стало чуть лучше.
Буду премного благодарен, если подскажете этому программисту как писать статьи лучше.
mr-garrick
29.09.2023 13:12За OTUS замечал уже не раз... Вроде бы должны учить доброму, светлому, но порой такое лепят, что есть большие сомнения что там вообще чему-то могут научить. Как из этой статьи человек, решивший познать основные принципы ООП может что-то вообще понять? В подобной статье от того же OTUS https://habr.com/ru/companies/otus/articles/525336/ описание инкапсуляции в корне противоречит описанию из этой статьи. Правда там про полиморфизм тоже начудили. Есть же простые понятные объяснения и определения, данные человеческим языком. Постоянно замечая ошибки, ляпы, закостенелость изложения материала, делаю выводы о квалификации тамошних преподавателей. А ведь они ещё и деньги просят за свои курсы. Никому бы не посоветовал. Ничего не имею против конкретного автора на за OTUS в целом накипело.
IgorAlentyev
29.09.2023 13:12+110 лет в программировании, все говорят что есть какие то мистические правильные и понятные описания ООП, но все 10 лет выходят статьи где мусолят одно и то же. Может вопрос не так прост, каким кажется?
vadimr
29.09.2023 13:12+1Тут такое дело. Если начать разбираться, то выяснится, что практика ООП противоречит принципам ООП. Так вообще часто бывает с теорией и практикой. Но тут надо для начала определиться, мы пишем о теории или о практике программирования. В данном случае – ни нашим, ни вашим.
mironoffsky
29.09.2023 13:12+4Сокрытие и инкапсуляция это разные вещи. Да, сокрытие по сути обеспечивает инкапсуляцию, но это не означает, что это одно и то же. Инкапсуляция - это объединение данных и методов по их обработке в одну структуру.
Полиморфизм тоже разный бывает. У вас в примере только полиморфизм подтипов, а есть ещё и ad-hoc полиморфизм, и параметримеский полиморфизм. Да и само определение полиморфизм куда глубже. Полиморфизм даже с ООП напрямую не связан, он связан с теорией типов. Полиморфизм - способность программы изменять поведение в зависимости от обрабатываемого типа.
В целом, базовые принципы ООП не такая простая штука, поэтому давать её в самом начале в таком упрощённом виде только вредить, придётся потом переучивать.
Golovar87
29.09.2023 13:12+1Инкапсуляция это и механизм языка, позволяющий скрывать детали внутренней реализации от пользователя и сохранять целостность данных. Собственно определений два и оба они верные.
raspberry-porridge
29.09.2023 13:12+1Примеры уровня: "Заходит как-то раз в бар бесконечное количество математиков..."
IvanG
29.09.2023 13:12+1Как создатель и руководитель курсов по C# я вижу ... Инкапсуляция в программировании сводится к тому, чтобы не давать доступа к важным данным напрямую
О боже, дальше читать не смог, инкапсуляция не про доступ
Magnetiq
29.09.2023 13:12+1И опять убеждаемся, что талант преподавать есть не у всех, кто преподает, и платить сотни тысяч за онлайн курсы глупо.
Вот прямо первый пример. Для чего мы инкапсулируем? Наш код обрабатывает наши же данные. Кто "из вне" может получить доступ к этой важной переменной? Почему не сделать её public и не использовать где понадобится? Что за ворота без забора посреди чистого поля? ????
tenzink
С наследованием перебор. Если развивать ваш класс машины, то начнут появляться вот такие уродцы
RedDiesel4wdManualTransmissionCar, GreenElectric4wdAutomaticTransmissionCar, ...
Lexo Автор
Согласен, что наследование тут (и не только :)) несколько излишне, однако, это показалось мне самым простым способом объяснить именно абстракцию без привнесения более сложных концепций.