ООП
July 12, 2023

Основы принципов ООП

Здорово! В данной статье ты сможешь познакомиться основными понятиями объектно-ориентированного программированием (ООП) и его парадигмами, узнать откуда данная штука взялась, и какие еще существуют методологии программирования.

Подходы программирования, история ООП

Для того, чтобы полноценно разобраться в ООП и понимать принципиальную разницу между различными концепциями программирования, начнем с самого начала и разберемся с понятиями императивного и декларативного подходов.

В 1930-х годах перед математиками встала проблема разрешения, сформулированная Давидом Гильбертом. Суть её в том, что необходимо найти алгоритм, который бы за конечное число шагов находил ответ на утверждение, задаваемое на формальном языке, истинно или ложно. Задача была опровергнута с помощью двух подходов, разработанных математиками Алонзо Чёрчем и Аланом Тьюрингом. Они показали (первый — с помощью изобретённого им λ-исчисления, а второй — теории машины Тьюринга), что для арифметики такого алгоритма не существует в принципе, т.е. данная задача в общем случае неразрешима.

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

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

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

В логическом программировании программы выражены в виде формул математической логики и компьютер для решения задачи пытается вывести логические следствия из них. Примером может послужить Prolog (как-то пробовал делать одну штуку на нем... такая срань, вы не поверите, лучше притвориться, что его не существует).

При императивном подходе программист явно указывает компьютеру, какие шаги нужно выполнить для достижения желаемого результата, и основывается на машине Тьюринга.

Бесконечная лента разбита на площадки помеченные символами. В каждый момент машине доступен ровно один символ. Состояние машины зависит от доступного символа и машина вольна этот символ изменять. Недоступные символы на ленте никак не влияют на поведение машины. Однако ленту можно двигать туда-сюда, это — элементарная для машины операция. Таким образом шанс есть у любого символа.

Лямбда-исчисление и машины Тьюринга функционально эквивалентны — что все, что можно вычислить с помощью машины Тьюринга, можно вычислить с помощью лямбда-исчисления, и наоборот, исходя из тезиса Чёрча-Тьюринга. То есть одну и ту же задачу можно выполнить разными подходами, но где-то нам данную задачу будет реализовать проще.

Одним из первых языков программирования, основанных на императивном подходе, был низкоуровневый язык ассемблера, появившийся в конце 40-х, дающий полный доступ к аппаратным средствам компьютера. Следующий шаг в развитии императивного подхода произошел к концу 50-х с появлением "высокоуровневых" языков, таких как Fortran, COBOL и ALGOL, которые стали предоставлять более абстрактные языковые конструкции для написания кода. И уже позднее в 60-х годах в результате развития императивного подхода берет свое начало ООП.

В 1961 году в Норвегии Оле-Йохан Даль и Кристен Найгаард начали совместную работу над созданием языка описание для компьютерного моделирования взрывающихся кораблей. Они поняли, что могут группировать корабли по разным категориям. У каждого типа корабля будет свой собственный класс, и класс будет иметь свое уникальное поведение и данные. Simula отвечала не только за введение концепции класса, но также за введение экземпляра класса.

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

Термин «объектно-ориентированное программирование» ввел Алан Кей при работе на компанию Xerox PARC над своим языком программирования Smalltalk. Этот термин использовался для обозначения процесса использования объектов в качестве основы для вычислений. Smalltalk был вдохновлен идеями, заложенными в Simula 67, но Smalltalk был разработан таким образом, чтобы он был динамичным. Объекты можно было изменять, создавать или удалять, и это отличалось от обычно используемых статических систем. А также Smalltalk был первым языком программирования, в котором была представлена ​​концепция наследования.

Повсеместное распространение и всеобщее признание ООП получило с дальнейшей реализацией идей SIMULA в таких мастодонтах, как C++ и Java. Сейчас же ООП является наиболее важным подходом и в процентах 95 в продуктовой разработке используется именно он (и только всякие глупые олимпиадники пытаются протолкнуть свою фпшку, код которой будет работать на 0.00005 наносекунды быстрее🤡).

ООП

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

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

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

По тому, как устроена работа с классами в C# (создание объектов, очистка памяти, обращение к методам, статические классы, спецификаторы доступа и пр.), повторяться не будем, это все можно найти в предыдущих лекциях курса по основам. Если не читали и не понимаете о чем речь, прочитайте, там все подробно разложено. А сейчас сконцентрируемся на парадигмах ООП: абстракция, инкапсуляция, наследование и полиморфизм.

Абстракция

Абстракция - набор значимых характеристик определенной категории объектов. Абстрагирование - это выявление свойств, связей и зависимостей для объекта, с целью выделить определенные закономерности, отбросив незначимые характеристики и скрыв детали реализации. Результатом абстрагирования являются абстрактные понятия (абстракции).

Абстракция в ООП подразумевает создание лаконичных классов, отвечающих только своему необходимому набору свойств.

Например, возьмем класс телефон. У него будет кнопка включения, метод для включения/выключения и метод, позволяющий кому-то звонить, есть телефон включен.

Инкапсуляция

Инкапсуляция отвечает за связь данных объекта с методами работы с ними, таким образом, объект должен контролировать свое внутреннее состояние. Одним из свойств инкапсуляции является сокрытие данных.

Таким образом, поля класса должны быть скрыты модификатором private, а для доступа к ним должны использоваться публичные методы интерфейса гетеры и сеттеры (подробнее про них тут), которые позволяют задать дополнительную валидацию данных для установки и получения определенных значений. Это повышает гибкость класса и гарантирует инвариантность объектов.

Наследование

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

Например, пусть у нас будет базовый класс Transport, который будет иметь название и будет уметь перемещаться, а также класс наследник Car. Тогда данное отношение в C# можно записать через двоеточие у класса наследника следующим образом:

public class Transport
{
    public string Name { get; set; }
    
    public void Move()
    {
        Console.WriteLine(quot;{Name} move");
    }
}

public class Car : Transport
{
}

Таким образом, класс наследник Car будет иметь тот же набор полей и методов, которые доступны в соответствии со спецификаторами доступа (про них мы разгоняли тут)

По умолчанию все классы наследуются от базового Object, даже если мы явно не устанавливаем наследование. Поэтому определенные классы Transport и Car кроме своих собственных методов, также будут наследовать и методы класса Object: ToString(), Equals(), GetHashCode() и GetType().

Все классы могут наследоваться, кроме помеченных модификатором sealed (данные классы будут являться как бы конечными в цепочке наследования) и статические классы.

public sealed class Car : Transport
{
}

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

public class Transport
{
    public Transport(string name)
    {
        Name = name;
    }

    public string Name { get; set; }
    
    public void Move()
    {
        Console.WriteLine(quot;{Name} move");
    }
}

public class Car : Transport
{
    public Car(string name, string model)
        : base(name)
    {
        Model = model;
    }
    
    public string Model { get; set; }
}

Полиморфизм

Полиморфизм - это способность объектов с одинаковой спецификацией иметь разную реализацию.

Если усе упрощать, то различают два типа полиморфизма: статический и динамический.

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

class Robot
{
    public void SaySomething(string words, int a)
    {
        Console.WriteLine(words);
        Console.WriteLine(a + 3);
    }

    public void SaySomething(string name, string words)
    {
        Console.WriteLine(quot;{name} - {words}");
    }
}
При вызове перегруженного метода будет искаться первый с наиболее подходящим набором параметров для приведения аргументов.

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

public class Transport
{
    public virtual void Move()
    {
        Console.WriteLine(quot;Move");
    }
}

public class Car : Transport
{
}

public class Ship : Transport
{
    public override void Move()
    {
        Console.WriteLine(quot;Swim");
    }
}

public class Program
{
    public static void Main()
    {
        SomeFunc(new Transport()); // Move
        SomeFunc(new Car());       // Move
        SomeFunc(new Ship());      // Swim
    }
    
    public static void SomeFunc(Transport transport)
    {
        transport.Move();
    }
}

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

Таким образом, компилятор не знает о переопределяемом методе и выбирает необходимый метод в рантайме по виртуальной таблице методов (но об этом я напишу уже в другой статье/посте, чтобы не затягивать эту:), и мы получаем ожидаемый результат, Сar выводит move, потому что метод не переопределялся, а для Ship вызывается переопределенный метод и выводится swim.

Также стоит отметить, что есть возможность отменить поведение механизма полиморфизма с помощью модификатора new.

public class Transport
{
    public virtual void Move()
    {
        Console.WriteLine(quot;Move");
    }
}

public class Ship : Transport
{
    public new void Move()
    {
        Console.WriteLine(quot;Swim");
    }
}

public class Program
{
    public static void Main()
    {
        SomeFunc(new Transport()); // Move
        SomeFunc(new Ship());      // Move
    }
    
    public static void SomeFunc(Transport transport)
    {
        transport.Move();
    }
}


А в этой лекции усе. С понятиями познакомились, базу узнали, фп от ооп различать научились... Могу пожелать здоровья психологического при освоении программирования, но это C#, а не C++, так что должно быть все в порядке, главное не сдаваться. В следующей статье поговорим о наследовании, абстрактных классах, интерфейсах, даааа... На канале сейчас возможно постить начну всякие полезности интересные, чтобы он не простаивал пока я большими материалами занимаюсь, так что подписывайтесь 👉https://t.me/serious_seesharp