Концепция класса
Ключевым понятием объектно-ориентированного проектирования и программирования является понятие класса.
Класс представляет собой обобщение однородных объектов реального мира. Другими словами, класс описывает общие свойства и поведение некоторой совокупности объектов. Свойства класса представлены в виде переменных класса, а его поведение – в виде методов (функций). Например, в виде класса может быть представлена концепция человека (персоны). Переменными такого класса будут: ФИО, текущие паспортные данные, номер свидетельства о рождении и т.п. В основу методов может быть положено следующее поведение: поступление на работу, получение заработной платы и т.д.
Класс описывает свойства и поведение объектов статическим способом. Это значит, что содержание класса не может быть изменено без изменения и повторной сборки программного обеспечения. Экземпляры классов (объекты) в ходе работы программы создаются и удаляются в вычислительной среде динамически, в соответствии с кодом программы. Если класс – это описание свойств объектов, то экземпляр класса отражает конкретный объект реального мира. Например, класс «человек» описывает свойства всех людей, а отдельный экземпляр этого класса – соответствует конкретному человеку.
Переменные класса
Класс содержит описание двух аспектов – структурного аспекта и аспекта поведения. Остановимся на первом. Структура данных класса представляет собой последовательность переменных. Порядок следования переменных важен и определяет их расположение в памяти при создании объектов.
Как и другие переменные, переменная класса относится к некоторому типу и имеет имя (уникальное в рамках класса). Тип переменной характеризуется размером, то есть количеством байт, занимаемых каждым значением. В качестве типа переменной могут быть использованы другие классы объектов. В этом случае нижестоящий объект (являющийся переменной класса) входит как часть в вышестоящий объект. Приведем пример класса на языке C++:
class Person { int number; -- Индивидуальный номер человека char name[10]; -- Имя int age; -- Возраст float height; -- Рост }; |
В соответствии с размером переменных на стадии компиляции для каждого класса формируется описание расположения переменных в памяти относительно начала объектов, например, следующего вида:
Имя класса | № переменной в классе | Тип переменной | Название переменной | Смещение от начала объекта | Размер переменной |
Person | int | Number | |||
Person | char[] | Name | |||
Person | int | Age | |||
Person | float | Height |
Иллюстративно структуру объектов класса в памяти можно представить следующим образом:
Все объекты, принадлежащие одному классу, имеют одинаковый размер, равный сумме размеров всех входящих переменных. Этот размер определяется на стадии компиляции и остается постоянным до тех пор, пока в структуру данных класса не будут внесены изменения.
Описанный аспект класса очень близок к понятию «структура» структурного программирования. Но есть и отличия, которые будут изложены при обсуждении управления доступом к переменным класса.
Методы класса
Второй аспект, описание которого содержит класс – это аспект поведения. Поведение класса (точнее всех объектов, принадлежащих классу) описывается совокупностью методов. Каждый метод представляет собой функцию, действующую в контексте класса. Этот контекст используется компилятором в ходе поиска переменной по ее имени. Когда компилятор встречает имя некоторой переменной в методе,
- сначала он ищет ей соответствие среди переменных, объявленных в рамках метода,
- затем среди переменных, объявленных в рамках того класса, к которому относится данный метод,
- и только затем среди глобальных переменных:
Рисунок 1 Последовательность поиска переменных в рамках метода по их имени
Как и функция структурного языка, метод объектно-ориентированного языка может принимать ряд переменных (являющихся параметрами), и может возвращать одну переменную (являющуюся результатом). Совокупность всех этих переменных представляет собой сигнатуру метода.
В объектно-ориентированном программировании методы классифицируются по различным критериям. Начнем рассмотрение с классификации по принципу статичности. Так, все нестатические методы принимают в качестве неявного параметра тот объект, в контексте которого они работают. Для таких методов использование переменной класса подразумевает обращение к переменной именно того объекта, который передан в качестве неявного параметра. Эти методы могут быть вызваны только в контексте некоторого объекта. Невозможно вызвать нестатический метод «вообще», не указав конкретный объект. Статические методы, наоборот, вызываются только вне контекста какого-либо объекта. Они не имеют неявных параметров.
Приведем пример объявления нестатического метода в C++:
class Person { float aMethod (int param1, float param2, char * param 3); }; |
Такой метод будет обязательно иметь неявный аргумент – указатель на тот объект, для которого он вызывается:
В случае, если метод является статическим, к его объявлению добавляется специальное ключевое слово static:
class Person { static float aMethod (int param1, float param2, char * param 3); }; |
Такой метод не будет иметь неявного аргумента:
Переменные класса также классифицируются по критерию статичности. Статической переменной считается переменная, объявленная в рамках класса, но не включаемая в каждый объект этого класса. Статические переменные существуют в единственном экземпляре для всех объектов класса. Даже если в памяти нет ни одного экземпляра класса, статическая переменная все равно уже существует. Статические переменные создаются в начале работы всей программы в целом и удаляются в конце ее работы. Так как статические методы работают вне контекста конкретного объекта, то они могут обращаться только к статическим переменным класса.
Так, если мы в любое место класса «персона» добавим статическую переменную «количество персон»:
class Person { int number; -- Индивидуальный номер человека static int personCnt; -- Количество персон char name[10]; -- Имя int age; -- Возраст float height; -- Рост }; |
то представление каждого объекта в памяти не изменится и останется тем же, что и без статических переменных:
Добавленная статическая переменная будет создана в единственном экземпляре для всех объектов в сегменте данных программы во время ее загрузки.
Следующим критерием классификации методов является их постоянство. Метод считается постоянным, если в нем не происходит изменения значений переменных того объекта, для которого он вызван. Приведем пример объявления постоянного метода в C++:
class Person { float aMethod (int param1, float param2, char * param 3) const; }; |
Другие виды методов мы опишем после изложения принципа наследования классов в объектно-ориентированном программировании.
Наследование
Механизм наследования является ключевым в концепции объектно-ориентированного программирования и сводится к следующему тезису: произвольный класс (класс-потомок, класс-наследник) может быть объявлен как наследник другого класса (класса-предка). При этом, потомок наследует все переменные и методы предка. Таким образом, если мы объявим класс «водитель», как наследника класса «персона»,
class Driver: public Person { char drivingLicence[10]; -- Номер водительского удостоверения }; |
то в памяти каждый объект класса «водитель» будет представлять собой расширение класса «персона» по составу переменных:
То же самое происходит с методами этих классов: методы класса «персона» будут входить в состав методов всех его наследников, в том числе класса «водитель».
В принципе, класс-потомок может быть наследован от нескольких классов-предков одновременно. Хотя в языках Java и C# на этот счет существуют ограничения, которые мы обсудим позднее.
Динамические методы
Еще одним критерием классификации методов класса является свойство динамичности. При обсуждении проблемы динамических методов, затронем тесно связанный с этим вопрос динамического связывания.
Существует два вида связывания (вызова) методов класса: динамический и статический. При статическом связывании компилятор указывает (в объектном коде программы) непосредственно адрес вызываемого метода. Например, следующим образом:
Рисунок 2 Вызов методов при статическом связывании
где стрелки – вызов соответствующего метода по фиксированному адресу.
При динамическом связывании – адрес вызываемого метода вычисляется в зависимости от того, к какому классу принадлежит тот объект, для которого он вызывается. В этом случае на стадии компиляции в объектном коде фиксируется лишь индекс (номер) динамического метода в рамках так называемой таблицы виртуальных методов. Таблица виртуальных методов создается компилятором для каждого класса, содержащего динамические методы, и содержит указатели на все динамические методы этого класса (каждый по своему индексу). Таблица виртуальных методов загружается в момент инициализации программы из ее объектного кода.
Для того, чтобы определить ту таблицу виртуальных методов, которая должна использоваться при вызове методов конкретного объекта, объекты всех классов, содержащих динамические методы, включают дополнительную переменную, указывающую на таблицу виртуальных методов. Например, если класс «персона» имеет динамические методы, то все его объекты будут представлены в памяти с использованием дополнительной переменной vmtPtr (указатель на таблицу виртуальных методов), не объявленную в явном виде среди переменных класса:
Эта дополнительная переменная заполняется каждый раз при создании в памяти очередного объекта. Код по ее заполнению встраивается в программу на стадии компиляции автоматически, прозрачно для программиста. Эта функция компилятора основана на том, что зная, к какому классу относится конкретный создаваемый объект, компилятор может определить адрес соответствующей таблицы виртуальных методов, создаваемой им самим же. В результате, каков бы ни был тип указателя на объект, переменная vmtPtr будет содержать указатель на таблицу виртуальных методов именного того класса, объект которого был создан. Например, мы можем создать класс «водитель» и присвоить указатель на него типу «указатель на персону»:
Person * p = new Driver; |
Несмотря на то, что на объект указывает переменная p типа «указатель на персону», скрытая переменная vmtPtr этого объекта будет содержать указатель на таблицу виртуальных методов класса «водитель».
При динамическом связывании компилятор автоматически встраивает в код программы следующий способ вызова динамического метода на основании его индекса:
- по указателю на объект, в контексте которого вызывается метод, берется указатель на таблицу виртуальных методов (с помощью дополнительной переменной, описанной выше);
- по индексу динамического метода берется указатель на вызываемый динамический метод;
- вызывается метод по полученному указателю.
Выше мы изложили понятие динамического связывания, теперь дадим определение динамическому методу. Динамический метод – это метод класса, к которому применяется метод динамического связывания, если явно не указано обратное – необходимость использовать статическое связывание. Все динамические методы класса присутствуют в его таблице виртуальных методов. Конечный метод является противоположностью динамического метода в том смысле, что к нему динамическое связывание никогда не применяется. Конечные методы отсутствуют в таблице виртуальных методов. Разницу между динамическим и конечным методам можно проиллюстрировать на следующем примере на языке C++:
Person * p = new Driver; p->aMethod(); |
Допустим, что в классе «персона» и классе «водитель» объявлено два метода aMethod с одинаковой сигнатурой. В том случае, если методы aMethod являются динамическими в обоих классах, будет вызван метод класса «водитель» в соответствии с правилом динамического связывания. Если же методы являются конечными, то будет вызван метод класса «персона» в соответствии с правилом статического связывания (так как переменная p указывает на объект класса «персона»).
Возможность использования индекса метода в таблице виртуальных методов в качестве ссылки на него основана на том, что если динамические методы в базовом и производном классах имеют одинаковую сигнатуру, то в таблицах виртуальных методов для обоих классов эти методы будут иметь одинаковый индекс. Например, для классов «персона» и «водитель» таблицы виртуальных методов будут содержать методы aMethod (если они динамические) с одинаковыми индексами:
В нашем примере одновременно используется динамическое и статическое связывание методов. M1 и M2 связываются статически. M1 и Person::aMethod, Driver::aMethod связываются динамически. На стадии компиляции в объектный код M1 встраивается вызов динамического метода по индексу 23. Так как все такие методы имеют одинаковую сигнатуру, то на процессе передачи параметров динамическое связывание никак не сказывается. На стадии выполнения программы, в зависимости от того, на какую таблицу виртуальных методов ссылается конкретный объект, вызывается либо динамически метод Person::aMethod, либо Driver::aMethod.
При этом, для динамического метода может быть указана необходимость использования статического связывания в явном виде. Например, на языке C++ для вышеприведенного примера это будет выглядеть так:
Person * p = new Driver; p->Person::aMethod(); |
В этом случае, какими бы не были методы aMethod – динамическими или конечными, – будет вызван метод класса «персона». Например, даже если методы aMethod являются динамическими, будет использовано статическое связывание:
Существует частный случай динамического метода – абстрактный метод. Абстрактный метод отличается тем, что он не имеет реализации (тела), для него задается только объявление (заголовок). Класс, содержащий абстрактные методы, также является абстрактным. В программе нельзя создать объекты абстрактного класса – будет выдана ошибка на стадии компиляции, – но можно объявлять указатели на абстрактные классы. Заметим, что существование указателя на абстрактный класс никак не подразумевает создание объекта абстрактного класса, так как указатель на базовый класс может указывать на объект производного класса. Например, если класс «персона» является абстрактным, то
Person * p = new Driver; |
допустимо, а
Person * p = new Person; |
недопустимо.
Абстрактные классы используются для описания обобщенных концепций, для содержательного использования которых необходимо программировать реализацию конкретных динамических методов. Если на стадии написания программы ясно, что создавать объекты некоторого класса не имеет смысла, то такой класс целесообразно объявить абстрактным. Но такой шаг не запрещает создавать массивы указателей на абстрактные классы и обрабатывать их унифицированным образом, вне зависимости от того, объект какого именно класса располагается по каждому из указателей массива.
Например, допустим класс «персона» является абстрактным и у него есть два производных класса: «водитель» и «спортсмен». Тогда мы можем объявить массив указателей на объекты класса «персона»,
Person * p[]; |
каждому указателю которого может быть присвоен как адрес объекта класса «водитель», так и адрес объекта класса «спортсмен»:
В результате, массив персон может обрабатываться унифицированным образом вне зависимости от истинного типа объекта, расположенного в памяти. Объекты классов «водитель» и «спортсмен» умышленно изображены на рисунке разного размера, чтобы подчеркнуть тот факт, что в памяти они могут занимать разное количество байт.
Конструктор и деструктор
Конструктор представляет собой нестатический конечный метод, вызываемый в момент создания объекта для его инициализации. Особенностью конструктора является то, что он не может иметь возвращаемой переменной. Код вызова конструктора вставляется в объектный код программы компилятором автоматически, прозрачно для программиста, каждый раз, когда выполняется команда создания нового объекта (будь то в куче или стеке, см. ниже про виды памяти).
Класс может иметь несколько конструкторов, отличающихся сигнатурой. Если в ходе создания объекта в его конструктор не передаются параметры, то вызывается конструктор без параметров, называемый конструктором по умолчанию. Если для класса не определен конструктор по умолчанию, то объект создается в памяти без выполнения инициализации, что приводит к случайным значениям его переменных (кроме неявного указателя на таблицу виртуальных функций – он заполняется верным значением в любом случае). Например, в следующем случае будет вызван конструктор по умолчанию, если он объявлен в классе «водитель»:
Person * p = new Driver; |
Если в ходе создания объекта параметры конструктора указаны явно, например,
Person * p = new Driver (10); |
то будет вызван конструктор, сигнатура которого соответствует передаваемым параметрам. Если такой конструктор не будет найден, или возникнет неоднозначность, будет выдана соответствующая ошибка компилятора.
В случае наследования, до вызова конструктора производного класса обязательно вызывается конструктор базового класса. При этом, если конструкторов базового класса несколько, то в объектно-ориентированном языке существует возможность указать какой именно конструктор вызывать. Например, на языке C++ это можно сделать следующим образом:
class Driver: public Person { Driver ():Person(0){} }; |
если для класса «персона» объявлен конструктор, принимающий целое число в качестве параметра.
Если же не указан ни один из конструкторов, то вызывается конструктор по умолчанию. Даже если в производном классе не объявлен конструктор, конструктор по умолчанию базового класса все равно будет вызван. Например, в случае
class Driver: public Person { }; |
при создании объекта класса «водитель» всегда будет вызываться конструктор по умолчанию для класса «персона», если он объявлен.
Деструктор представляет собой нестатический метод, вызываемый в момент уничтожения объекта. Деструктор, как и конструктор, не имеет возвращаемой переменной. К тому же, деструктор не имеет параметров, и класс может иметь только один деструктор. Код вызова деструктора вставляется в объектный код программы компилятором прозрачно для программиста каждый раз, когда некоторый объект прекращает свое существование (будь то в стеке или куче). Если для класса деструктор не задан, то память, выделенная под объект, освобождается без каких-либо дополнительных действий. В случае, если объект захватил некоторые ресурсы (например, память или файловые дескрипторы), отсутствие деструктора приведет к тому, что они будут потеряны для дальнейшего использования в программе. Поэтому, для корректной работы программы, классы, захватывающие ресурсы, необходимо дополнять деструктором, освобождающим эти ресурсы.
В случае наследования, компилятор для уничтожения объекта помещает в объектный код программы вызов как деструктора производного класса, так и деструктора базового. При этом, сначала выполняется вызов деструктора производного класса. Только после вызова деструкторов всех предков, вызывается деструктор самого класса, после чего память, выделенная под объект, освобождается.
Деструктор может быть динамическим. В этом случае, в ходе удаления объекта, будет выполнена последовательность вызовов деструкторов именно для того объекта, который располагается в памяти, а не того объекта, на который показывает указатель. Эту особенность можно проиллюстрировать на следующем примере:
Person * p = new Driver; delete p; |
В этом примере, если деструктор класса «персона» виртуальный, то в ходе уничтожения объекта, в соответствии с правилом динамического связывания и правилом вызова деструкторов, будет вызван сначала деструктор класса «персона», затем деструктор класса «водитель». Если деструктор класса «персона» не является виртуальным, то в ходе уничтожения объекта, в соответствии с правилом статического связывания и правилом вызова деструкторов, будет вызван только деструктор класса «персона», так как удаляемый указатель показывает на объект класса «персона».
Виды памяти и создание объектов
Во время работы программы переменные (в том числе объекты) могут создаваться в различных местах оперативной памяти: в сегменте данных, в стеке и в куче. Эти виды памяти отличаются принципом создания переменных в них.
В сегменте данных создаются переменные, существующие все время от начала работы программы до завершения. В эту категорию попадают все статические переменные (в том числе глобальные). С точки зрения программиста эти переменные всегда существуют и могут быть доступны.
Стек используется для передачи параметров в методы и для создания внутренних переменных методов, существующих только во время выполнения метода или его части. Создание переменных в сетке встраивается в объектный код программы компилятором и происходит прозрачно для программиста. Обращение программиста к переменным метода компилятор преобразует в обращение к стеку по фиксированному смещению от вершины (вглубь стека).
При вызове метода ее параметры помещаются на вершину стека. Если в методе объявлены другие переменные, то они также помещаются на вершину стека. В момент компиляции команды с обращением к той или иной переменной компилятор вычисляет по какому смещению располагается эта переменная относительно вершины стека и встраивает в объектный код программы ссылку на позицию в стеке по фиксированному смещению от вершины. При завершении работы метода или вложенного блока операторов, все переменные, объявленные в этом методе (или блоке), снимаются с вершины стека. Например, если в программе объявлены следующие функции:
void methodA (int x, float y) { int c; methodB (x, y, c); … -- Точка X } void methodB (int x, float y, int d) { int c; … -- Точка Y } |
то в точке Y в стеке будут существовать следующие переменные:
А в точке X следующие:
Использование кучи зависит от языка программирования. Например, в C++ создание переменных в куче полностью управляется программистом. После создания переменной программисту доступен указатель на ее начало. За удаление переменной в конце ее жизненного цикла отвечает сам программист, что служит причиной большого количества ошибок, приводящих к утечке памяти. В Java, наоборот, использование кучи прозрачно для программиста: если переменная представляет собой сложный объект, то она автоматически размещается в куче. Когда последняя ссылка на такой объект удаляется, он помечается неиспользуемым. В Java-машине существует регулярный процесс, называемый сборщиком мусора, который и отвечает за освобождение памяти из-под неиспользуемых объектов.
Если переменная представляет собой сложный объект, то во время ее создания вызывается конструктор, а во время уничтожения – деструктор, где бы она не находилась: в сегменте данных, в стеке или в куче.