Классы и объекты
Содержание |
История развития ООП
Для того, чтобы понять, что же такое классы и объекты, сперва необходимо проследить историю развития программирования. А конкретнее, историю возникновения концепции ООП. Автор верит, что знание истории возникновения тех или иных мыслей и идей может помочь читателю осознать необходимость тех или иных нововведений, и главное — их преимуществ перед существовавшими в то время решениями.
Возникновение языков программирования
На заре зарождения вычислительных машин их приходилось программировать по истине "вручную". Все что было в руках программиста это пульт управления ЭВМ. На шестнадцатиричной клавиатуре (а еще раньше на пульте тумблерами) программист задавал некоторый адрес ячейки памяти, затем он мог либо выполнить операцию чтения — тогда на табло появлялись цифры, соответствующие значению ячейки памяти, либо операцию записи — при этом по указанному адресу записывалось значение, набранное на клавиатуре данных. Затем, программист переходил к следующей ячейке и так повторялось до тех пор, пока в память ЭВМ не была внесена вся программа. На программистах (точнее, на операторах ЭВМ) лежала огромная ответственность! Одна ошибка, один неверно установленный переключатель или одна пропущенная команда неминуемо вели к ошибкам в работе программы, а следовательно и к ошибкам в рассчетах. Могли потребоваться недели и даже месяцы на поиск этой ошибки и на ее исправление! Естественно, ни о каких языках программирования тогда не могло идти и речи.
Появление ассемблера
Впоследствии, программисты смекнули, что команды можно записывать в виде мнемонических обозначений или мнемоник — то что раньше применялось только для удобства записи на бумаге — было стандартизировано и приспособлено как язык общения человека и ЭВМ. Так появился первый язык программирования — язык ассемблера. Конечно, языком его можно назвать с некоторой натяжкой, ведь он не обеспечивал и десятой доли тех возможностей (в роде автоматического разбора арифметических выражений), которые мы привыкли ассоциировать с языками программирования. Тем не менее, ассемблер выполнял свою главную и основную функцию — избавлял программиста от необходимости работать с памятью (и адресами) напрямую. Вместо этого программист запиывал свои команды в стандартной форме, понятной ЭВМ. Далее выполнялась программа транслятор, которая преобразовывала исходный текст программы в поток машинных команд, которые уже можно исполнять.
Концепция языка высокого уровня
...С увеличением сложности программ программировать на ассемблере становилось все сложнее и сложнее. Ввиду естественных ограничений человеческой памяти и внимания написание программ и их отладка стали настолько сложными что люди всерьез подошли к рассмотрению идеи языка высокого уровня — некоторой системы обозначений и абстрактных команд которая позволила бы записвать программы в абстрактной форме, не заботясь о том как располагать в памяти код и данные, как их структурировать и т д. Всю эту работу брал на себя компилятор этого языка. Кроме того, он обеспечивал программиста уобным способом записи математических выражений — в естественной форме. При этом, компилятор сам "разворачивал" эти выражения в наборы инструкций ассемблера, попутно подставляя значения констант и адреса переменных. Это дало возможность программистам записывать формулы вычислений в натуральном виде, что уменьшало трудозатраты, ускоряло написание программ и уменьшало вероятность ошибок. Тем не менее многие авторитеты того времени очень негативно отзывались о языках высокого уровня. В то время языки были довольно несовершенными, и генерировали "ужасный" с точки зрения программистов код. Код был неоптимален, занимал огромное по тем временам количество памяти и работал медленнее чем та же программа, написанная на ассемблере. Смешно сказать, но в то время многие не верели в то что будущее за ЯП высокого уровня; их считали не более чем игрушкой для "чайников", возжелавших вообразить себя настоящими программистами.
Но время шло, и количество приверженцев нового подхода постоянно увеличивалось. Сами же компиляторы становились все более мощными и генерировали все более компактный и оптимальный код. Дошло до того, что компилятор с оптимизатором в некоторых случаях генерировал код, более качественный, чем это делал программист. С этого момента ЯП высокого уровня заняли свое место в истории и в инструментарии любого разработчика.
Структурное программирование
С развитием языков программирования появились новые концепции и новые парадигмы программирования. От линейного моноблочного программирования, при котором программа писалась единым "куском" от начала до конца, перешли к программам модульным и структурным, при которых программа представляла уже совокупность процедур (функций) которые вызывали друг друга в ходе работы программы. Процедуры представляли собой подпрограммы, решющие отдельные частные задачи. При этом, код получался более читаемым и облегчалась его отладка.
Опять же, в ходе усложнения решаемых задач и, вследствие этого, увеличения количества переменных с которыми приходилось работать программисту, возникла идея группировки некоторых переменных в группы, или структуры. При этом сруктуры формировались по назначению и содержали в себе переменные, имеющие отношения к одной и той же сущности. Это значительно повысило читаемость программ и уменьшило количество ошибок в них.
Объектно-ориентированное программирование
Ну и наконец, одна светлая голова додумалась до мысли "а что если в структурах группировать не только переменные, но и сами процедуры которые должны работать с этими переменными?". В результате получилось то что мы сейчас называем классом — то есть, некоторая обособленная функциональная сущность которая сама хранит свои данные, а главное сама умеет их обрабатывать. Теперь программисту не нужно помнить, какая из процедур отвечает за некоторое действие над такими то переменными — он просто берет объект и работает с ним. Все что происходит с объектом внутри — это его личное дело.
Ну и последним шагом к современному пониманию программ явились концепции полиморфизма, инкапсуляции и наследования. Не будем пока углубляться в суть этих понятий, отметим только, что это введение их и сформировало современное понимание объектно ориентированного программирования.
При написании программы на объектно-ориентированном языке, программист строит математическую модель взаимодействия различных сущностей. Каждая из сущностей это свой мир, у которого есть свои законы и особенности. При этом, сущности могут быть как конкретные, вроде "сетевой интерфейс", "файл", так и совершенно абстрактные например "отношение" или "ошибка". Программист описывает каждую из сущностей в отдельности, обособлено от остальных. При этом, вся необходимая для работы информация хранится внутри, а для взаимодействия с внешним миром предусмотрен интерфейс — некоторая совокупность свойств данной сущности (отражающих ее внутреннее состояние) и способов взаимодействия с ней — методов.
В ходе работы программы, сущности могут взаимодействовать, читая и записывая свойства и вызывая методы друг друга, использовать друг друга как подсистемы, порождать новые сущности и т д. Получается, что при написании программы, программист переносит свое внутреннее представление того, как он видит эту программу, то из чего она состоит и как отдельные ее части взаимодействуют. Теперь не приходится адаптировать свое понимание проблемы к конкретным инструментальным средствам и возможностям языка программирования (конечно, это все же происходит, но уже гораздо менее заметно).
В терминах современных языков программирования такие сущности называются классами, в смысле классами сущностей. А отдельные представители этих классов называются экземплярами, инстанциями (на английский манер) или объектами. Более подробно, различие между классами и объектами будет рассмотрено ниже.
Итак, любой современный объектно-ориентированный язык оперирует понятиями классов и объектов. Точно такой же подход нашел свое применение в нашей виртуальной машине. Основой всей платформы Gide является объектно-ориентированный принцип. Причем, в этом смысле она является более объектно ориентированной, нежели традиционные ЯП вроде C++. В C++ существуют понятия элементарных типов. Это сделано в целях производительности и было продиктовано архитектурой самого языка. В классических языках программирования, элементарные типы так или иначе отражают сущности из "реального мира". Например, целочисленные типы int и short соответствуют 32х и 16ти разрядным регистрам процессора, указатели и строки соответствуют представлению данных в памяти и т д. В Gide это не так. Все с чем оперирует виртуальная машина — это объекты. Соответственно не существует понятия элементарных типов (просто нет критерия который бы позволил отделить одно от другого).
Язык K++ в полной мере наследует идеологию Gide. Скажем, для него нет отличия между типом int (целое число) и некоторым пользовательским классом MyWeirdClass: везде, где можно использовать int, можно использовать MyWeirdClass и наоборот. Более того, это позволяет работать с системными классами так же, как с пользовательскими! Например, ничто не мешает унаследовать свой класс от класса int, равно как ничто не мешает определить математические операторы для класса MyWeirdClass и использовать объекты этого класса в арифметических выражениях. При этом изменится логика работы всего языка. К примеру, после добавления некоторого метода к классу int можно будет вызывать методы у его экземпляров, даже тех что представлены числовыми константами внутри самого языка!
Понятие класса
Что такое класс проще всего объяснить на примерах. Представьте, что вас спрашивают "что такое яблоко?". Скорее всего, вы ответите что-то вроде: "яблоки, это вкусные плоды растущие на деревьях — яблонях; они бывают разных цветов и размеров". Заметьте, что когда мы описываем яблоки как понятие, мы не имеем в виду некоторый конкретный объект, а скорее описываем наше обобщенное представление о них. Если же вас попросят описать совершенно конкретное яблоко, лежащее на блюдечке перед вами, вы будете говорить именно о нем по другому: "это яблоко, оно красное, сочное, судя по всему спелое. с черенком, на котором остался листик, и маленькой червоточинкой".
Разница заключается в том, что когда вы говорили о яблоках, вы описывали свое представление яблок, как класса объектов. Когда вы описывали яблоко, то вы имели в виду конкретный экземпляр, или объект. Говоря о классе вы можете описать только те свойства, что принадлежат всем яблокам, когда же вы описываете объект, то в первую очередь имеете в виду его индивидуальные особенности (свойства). Тем не менее, описание объекта начинается с упоминания его класса ("это яблоко,..."), а затем уже свойств объекта (ведь сочным и спелым может быть и апельсин). Это важная особенность объектно-ориентированного подхода.
Другой пример: если вас попросить "представьте дерево", то вы либо представите некоторое совершенно абстрактное, усредненное дерево, либо попросите уточнить, какое именно дерево имеется в виду. Ваше сознание из имеющейся информации смогло уяснить только самые общие сведения о классе. Но этой информации не достаточно, для более детального описания. Это тоже важно, поскольку в этом простом примере кроется сущность механизма наследования — постепенного уточнения классами потомками общих черт своих предков. Таким образом, и яблоня и груша — деревья. Но яблони отличаются от груш. Получается, что классы яблони и груши имеют общего предка — класс дерево.
Таким образом, понятия классов и объектов, это не математическая абстракция, а скорее часть нашего восприятия мира, того как мы мыслим.
Из примеров выше мы смогли уяснить следующее:
- Классы, это некоторые абстрактные сущности, задающие общие черты своих объектов
- Все объекты одного класса похожи друг на друга, но имеют некоторые индивидуальные особенности
- Классы могут наследоваться, расширяя набор свойств класса родителя своими собственными
Перейдем теперь ближе к основной теме нашего повествования, а именно языку К++:
С точки зрения языка класс представляет собой набор следующих элементов:
- полей, т.е. переменных, хранящих индивидуальные особенности объектов,
- методов, т.е. функций, определяющих поведение данного объекта;
- свойств, определяющих взаимодействие других объектов с объектами данного класса.
Поля — это переменные, которые относятся к некоторому конкретному экземпляру нашего класса. Каждый экземпляр имеет свою копию набора переменных, таким образом они могут хранить свое состояние (например показатель "спелости" в примере с яблоками). Эти переменные доступны только самому классу, доступ извне для них запрещен. Для того, чтобы частично разрешить этот доступ, применяются свойства. Сами свойства будут описаны позже, здесь стоит отметить только то, что свойство может быть доступно "только на чтение", "только на запись" или "и на чтение и на запись". Свойство может быть связано либо с некоторым полем, либо с методом. Например, если свойство доступно "только на чтение" то его можно использовать для получения значения, но не для его записи (то есть, такое свойство не может фигурировать в качестве lvalue).
Методы — это тот самый связанный с данными код (вспомните лирическое отступление выше) который, естественно, может работать с переменными объекта (то есть с полями) и служит для описания собственно поведения данного класса объектов.
Класс может иметь одного или нескольких родителей (опять же, подробнее об этом см. ниже)
Методы и свойства класса могут находиться в различных областях видимости. Это обеспечивается с помощью спецификаторов доступа:
- private — Частная собственность! Видимость только внутри методов данного класса
- protected — "Семейная реликвия", доступ внутри методов данного класса и всех его дочерних классов
- public — видимость и доступ для всех
Примечание: По умолчанию методы имеют видимость private, в то время как свойства — public.
Приведем наконец пример объявления класса:
<source lang="kpp" line="1"> class MyWeirdClass {
var m_x = 0; // поле m_x, изначально проинициализированное нулем const m_y = 1; // поле-константа m_y
// методы класса public const function int get_mul() { return m_x * m_y; } public function void set_mul(int x) { m_x = x / m_y; }
// свойство класса public property mul read get_mul write set_mul;
} </source>
- 1
- Как у любого нормального разумного существа, у класса есть "голова" и "тело". Ключевое слово class начинает объявление класса. Далее за ним следует идентификатор имени класса, после чего идет тело.
- 3-4
- Здесь мы видим объявление двух полей класса — переменной m_x и константы m_y, которые, подобно обычным переменным инициализируются тут же, на месте объявления (камень в огород C++).
- 7-8
- Для доступа к состоянию объекта, определены два метода: аксессор get_mul() и мутатор set_mul(). Подобные конструкции применяются настолько часто, что им были даны специальные имена. Как видно из названия, первый метод дает доступ к значению, второй изменяет или мутирует его.
- 11
- Завершается объявление класса объявлением свойства mul, которое связывается с аксессором и мутатором. Думаю, читатель уже догадался, что это свойство типа "чтение и запись". Таким образом, при обращении к свойству на чтение, будет вызыан аксессор, а результат его выполнения будет возвращен в качсетве значения свойства. И наоборот, при попытке записать в свойство некоторое значение, будет вызван мутатор, в качестве аргумента которому будет передано это самое значение, а уж сам мутатор позаботится о том чтобы оно было "доставлено по адресу".
- Примечание: Зачем нужны такие сложности и зачем дублировать вроде бы одинаковый функционал, будет описано ниже.
Понятие объекта
Собственно, понятие объекта уже много раз было затронуто выше по повествованию. Поэтому здесь приведем лишь небольшое определение: Под объектом подразумевается экземпляр того или иного класса, т.е. некоторая сущность, поведение которой задается соответствующим классом.
Для создания объекта того или иного класса служит оператор new: <source lang="kpp">
var myWeirdObject = new MyWeirdClass;
</source>
Здесь мы видим типичную конструкцию объявления переменной, однако в инициализаторе переменной находится всего один оператор, за которым следует идентификатор имени класса, экземпляр которого мы хотим создать.
Наследование
Под наследованием классов понимается механизм такого создания(объявления) класса, при котором он расширяюет функционал одного или нескольких уже существующих классов (родителей). Вспомните пример с деревьями. Когда мы говорим, что класс яблоня наследуется от класса дерево, это значит что яблоня унаследует все свойства своего класса-родителя, некоторые из которых он может изменить, ну и дополнить своими собственными свойствами. Опыт нам подсказывает, что у любого дерева есть листья (для простоты не будем вспоминать про хвойные), однако не любое дерево плодоносит яблоками. Если же рассмотреть сами яблоки, то можно сказать, что класс яблоко унаследован от класса фрукт, который определяет что фрукты (и соответственно яблоки) должны расти на деревьях.
Таким образом, наследование гарантирует, что к дочерним классам применимы все операции, доступные в родительском классе: любой алгоритм, работающий с объектами родительского класса, может работать с объектами его дочерних классов.
Для задания наследования, в объявлении класса следует указать ключевое слово extends, за которым необходимо перечислить список идентификаторов классов-родителей, разделяя их запятыми:
<source lang="kpp" line="1"> // коробка class Box {
// из чего сделана коробка? public const function string material() { return "Картон"; }
// что в коробке? public const function string contents() { return "Пусто"; }
}
// коробка с картошкой class BoxWithPotatoes extends Box {
public const function string contents() { return "Картошка"; }
}
function OutputBox(const Box b) {
STDOUT.print("Материал: " + b.material() + ", содержит: " + b.contents() + "\n");
}
function main() {
var b1 = new Box; var b2 = new BoxWithPotatoes; OutputBox(b1); // Материал: Картон, содержит: Пусто OutputBox(b2); // Материал: Картон, содержит: Картошка
} </source>
- 2-7
- В этом примере мы создаем класс Box (коробка), который представляет собой некоторую коробку. Мы определяем ее свойства, такие как материал и содержимое.
- 10-12
- Далее мы определяем класс BoxWithPotatoes (коробка с картошкой), который наследуется от класса Box, и тем самым заимствует свойства материала и содержимого, но первое он переопределяет (в случае методов это называется перекрытием) собственным методом.
- 14-16
- Мы определяем некоторую функцию для работы с нашими классами, которая будет отображать их состояние. Заметьте, что в качестве аргумента функции передается экземпляр класса Box, то есть класса-родителя. При этом, мы предполагаем что любой класс, унаследованный от базового класса будет обладать необходимым нам интерфейсом, а именно методами получения информации о свойствах (аксессорами мы их не называем, потому что они не связаны с конкретным полем; это было бы неверно).
- 18-23
- Объявляется функция main(), внутри которой и происходит самое интересное. Сначала мы создаем два экземпляра b1 и b2 классов Box и BoxWithPotatoes соответственно. А затем вызываем вышеописанную функцию OutputBox(), которая отображает содержимое. Вывод в терминал (написан в комментарии к вызову) показывает как это все работает.
Примечание: при использовании множественного наследования существует одно серьезное ограничение: его нельзя применять для наследования от классов стандартной библиотеки. Это ограничение связано с архитектурой платформы Gide. Однако, его можно обойти с помощью создания классов-оберток для системного класса, с последующим наследованием от него нового класса.
Методы
Метод — это некоторый код, связанный с объектом и управляющий его поведением. Управление может заключаться в изменении переменных постояния объекта (полей), либо выполнением некоторых операций над ними.
При объявлении метода могут быть указаны следующие ключевые слова, в указанном порядке:
- private, protected или public — определяют область видимости метода;
- static — указывает, что метод является статическим (см. ниже)
- const — метод не изменяет объект
- function или constructor указывает, что объявляется — метод или конструктор (см. ниже)
- const — метод возвращает результат, который нельзя изменять
После этого указывается тип, возвращаемый методом. Если он опущен — возвращается динамическая переменная; если вместо типа указано ключевое слово void — метод не возвращает результата. Следом за типом идет имя метода, затем — перечисление аргументов в скобках. Подробнее об объявлении функций и передаче параметров можно прочитать в главе Функции.
Тело метода может быть объявлено как непосредственно в теле класса, так и вынесено за его пределы, например:
<source lang="kpp"> class MyClass {
public const function string F1() { return "smth"; } public const function string F2();
}
function string MyClass::F2() {
return F1();
} </source>
- 6
- При вынесении функции за пределы класса, имя соответствующего метода именем класса из которого он был вынесен, которые отделяются друг от друга символом "двойное двоеточие" ("::"), дополнительно в заголовке вынесенной функции необходимо указать тип возвращаемого значения и параметры функции, все остальные ключевые слова указывать не обязательно.
- 7
- В теле метода доступны все поля, методы и свойства данного класса и его предков. Например, в коде, приведенном выше, метод F2 вызывает метод F1 для того же объекта.
Статические методы
Статический метод класса — это метод, относящиеся к данному классу, но не объекту этого класса. Т.е. это некоторая вспомогательная для данного класса функция.
При объявлении статического метода нужно указать ключевое слово static.
В теле статического метода нет возможности напрямую обращаться к другим методам данного класса, т.к. статическому методу недоступен объект класса. Фактически, единственным отличием статического метода от обычной функции является то, что такой метод может обращаться к защищенным полям и методам класса.
Пример: <source lang="kpp"> class MyClass {
public static function string Info() { return "Я MyClass!"; }
}
function f() {
// Вызов статического метода: var myClassInfo = MyClass.Info();
} </source>
Конструкторы
Конструктор класса — это специальный метод, инициализирующий объект класса. С точки зрения языка, конструктор — это статический метод класса, возвращающий экземпляр данного класса.
Таким образом, следующие объявления в рамках класса MyClass эквивалентны: <source lang="kpp"> public constructor Create(); public static MyClass Create(); </source>
Тело конструктора чаще всего выглядит следующим образом: сначала создается экземпляр класса при помощи оператора new, затем производятся некоторые действия, инициализующие этот объект, и, наконец, этот объект возвращается в качестве результата: <source lang="kpp"> class MyClass {
var int m_X; public constructor Create(const int x = 0) { var self = new MyClass; self.m_X = x; return self; }
}
function f() {
var myObj = MyClass.Create(7);
} </source>
Поля
Поле класса — это некоторый объект, используемый объектом данного класса.
Объявление поля начинается с одного из трех ключевых слов:
- var — объявление "обычного" поля;
- const — данное поле является константой и не может быть изменено;
- mutable — значение данного поля не влияет на состояние объекта, и его можно менять даже в методах, объявленных константными.
За ключевым словом следует тип поля и идентификатор его имени. Тип поля может быть опущен. После имени может стоять символ "=" и выражение, инициализирующее значение данного поля. В целом, синтаксис тот же, что и при объявлении обычной переменой или константы:
<source lang="kpp"> var int m_x; const m_y = 0; var m_stream = new stream; </source>
Тип поля определяется по следующим правилам:
- если тип указан явно, ничего определять не надо;
- если тип не указан, но при объявлении использован инициализатор — типом становится тип результата инициализатора;
- в противном случае, для поля устанавливается динамический тип.
Примечание: В K++ доступ к полям имеет только объект класса — т.е. фактически, все поля находятся в закрытой (private) области видимости. Для предоставления доступа к полям следует использовать свойства (см. ниже).