лекции (1998) (Буров) (1161123), страница 13
Текст из файла (страница 13)
char body[50];
int top;
void Push (char x);
char Pop ();
…
static int Errcode;
};
Здесь у нас stack одновременно и имя модуля и типа, нам не надо придумывать два различных имени.
Мы уже говорили про назначение Errcode, в данном случае с помощью ключевого слова static можно добиться того, что для всех экземпляров класса будет существовать одна единственная переменная Errcode.
Заметим, что мы написали только определение класса. Где следует писать реализацию класса? В любом месте ниже:
void Stack::Push(char x) {
this->body[this->top++] = x;
}
Что сразу бросается в глаза? Конечно, тот факт, что и в Modula-2 и в других языках мы передавали функциям Push и Pop по два параметра. Здесь же для Push передается один параметр, для Pop – вообще ни одного. Одним из фундаментальных свойств класса является то, что в каждую функцию или процедуру класса неявно передается ссылка на экземпляр класса, ссылаться на который можно с помощью ключевого слова this. Но, вообще говоря, в C++ считается, что все имена полей локализованы, поэтому мы можем опускать this-> и писать следующим образом:
void stack::Push(char x) {
body[top++]=x;
}
В некоторых случаях использование this может пригодиться, например, для передачи ссылки на себя в другие объекты.
Возникает вопрос – откуда берется этот неявный параметр? Поскольку stack – тип данных, то мы можем объявлять переменные этого типа:
stack S;
и писать:
S.Push(‘e’);
теперь понятно, что значение this берется из переменной S.
Заметим, что у нас есть вполне естественный и понятный синтаксис:
<имя объекта>.<имя члена>,
такой же, какой был в языке С. Но теперь он стал содержать в себе гораздо больше – если <имя члена> - функция, то в качестве неявного параметра ей передается указатель на соответствующий экземпляр объекта.
Заметим, что здесь степень интеграции гораздо выше. И это не только вопрос удобства.
Посмотрим немного на то, сколько места занимает объект типа Stack: массив body и переменная top:
BODY | TOP | ||||||
Errcode в этой области не размещается, т.к. он размещается «где-то там» в единственном экземпляре, чтобы быть доступным для каждого объетка класса. Это действительно переменная класса, она одна на всех.
Чем удобнее статическая переменная по отношению к глобальной? Вообще говоря, использования глобальных переменных надо избегать везде, где только возможно, так как доступ к глобальным переменным слишком общий, и их очень легко испортить.
Имеет ли смысл обращение:
S.Errcode?
вобщем да, таким образом мы получим доступ к той самой единственной статической переменной класса. Объявив переменную S2 и обращась:
S2.Errcode
мы опять же получим ту же самую переменную. Однако чаще принято (т.к. более удобно) обращаться к статическим переменным и функциям через вызов:
stack::Errcode
Одним из преимуществ такого обращения является тот факт, что к статическим элементам класса можно обращаться, даже не имея переменных данного класса.
А какой смысл в статических функциях? Чем они должны быть непохожи на обычные функции? Это функции, не имеющие смысла для конкретного члена, но имеющие смысл для класса в целом. Например, если они оперируют только статическими переменными.
Предположим, что мы не хотим иметь возможность доступа к Errcode извне (чтобы не испортить ее), а хотим, чтобы только функции Push и Pop могли ее модифицировать. Это сделать достаточно просто. Мы объявляем Errcode, как private, оно будет означать, что только функции-члены данного класса могут обращаться к данной переменной. Как к ней получить доступ извне? Следует написать функцию, которая будет возвращать значение Errcode. Она, очеведно, должна быть статической.
…
static int GetError () {return Errcode;}
private static Errcode;
…
Заметим, что в статические функции не передается указатель на какой-либо экземпляр объекта, хотя бы потому, что непонятно, откуда его брать. Следовательно, если написать
int GetError () {
body[top++]=0;
};
то компилятор схватит нас за руку, т.к. в ней нет никакого параметра this. Большим преимуществом является то, что мы просто не сможем написать с этой точки зрения неправильную статическую функцию.
Обратим внимание на то, что в предпоследнем примере тело функции GetError () было написано прямо в определении класса. Это допустимо. Мало того, в Java только так и можно делать, там нет отдельного места для реализации функций. Впрочем, такие вещи, как создание определений на основе полного модуля следует отдавать на откуп интегрированной среде или каких-то внешних средств, так как встраивать такие вещи в язык – несколько лишнее.
Рассмотрим еще один интересный ньюанс. Дело в том, что современный архитектуры процессоров подразумевают конвейерность, то есть когда процессор за один такт обрабатывает несколько команд, эти команды загружаются сразу по несколько штук. Очевидно, что операция перехода заставляет очищать конвейер и заполнять его заново другими инструкциями. Поэтому на уровне микроархитектуры подобные функции, как GetError, возвращающие только значения переменных являются крайне неэффективным средством. Вирт, например, в Oberon-2 специально ввел переменные, доступные только для чтения, чтобы разрешить эту проблему. Так как Страуструп хотел перенять из языка С в C++ все его хорошие качества, в том числе и эффективность, то он сделал следующее: inline функцию. Это ключевое слово - подсказка компилятору, что данную функцию можно включить напрямую, так как вызов ее (а следовательно операции со стеком, сбросом конвейера и все это несколько раз) будет просто дороже.
Возвращаясь, к определению класса отметим, что реализация функции внутри определения класса C++ означает как раз то, что ее следует включить inline. Это, конечно, не более, чем рекомендация компилятору, т.к. он имеет полное право ее проигнорировать. Как правило игнорируется inline у функций, где есть любые операторы перехода, слишком длинный код. Заметим, что inline концепция более общая, чем, например, переменные read-only.
Лекция 11
Мы с вами определили, что для того, чтобы связать с именем типа не только соответствующую структуру данных, но и соответствующий набор операций и атрибутов данных, необходима некая логическая структура, а именно, модуль. В разных языках программирования модуль имеет разные виды. Мы рассмотрели два подхода – концепцию модуля как обертки (Модула-2, Оберон, Ада), и подход С++, который, в некотором смысле, более элегантен. Понятие модуля несколько ограничено (оно должно быть шире, чем просто определение типа данных), поэтому в С++ введена новая конструкция, которая называется класс. При этом, структура (struct) является тем же классом, и отличается только тем, что у нее по умолчанию все члены открыты (public), тогда как у класса по умолчанию все члены скрыты (private).
Со структурой можно работать точно так же как и с классом, хотя полностью организовать совместимость нельзя из-за "дурных" свойств языка Си, в котором при описании переменной структурного типа необходимо использовать ключевое слово struct перед именем соответствующего типа. Образуется как бы новое пространство имен, часто можно встретить функцию такого рода: struct time time(…). И если структура описана внутри другой структуры, то она не является локальной и может свободно использоваться также как и внешняя структура. При описании класса, имя этого класса становится обычным именем типа. Причем отказаться от такого подхода к структурам в С++ нельзя было из-за соображений эффективности.
Но вернемся к классам. Класс состоит из членов-данных и из членов-функций. Есть понятие статических членов класса (это могут быть и данные и функции), которые не принадлежат ни к одному экземпляру класса, но доступны для всех. При этом, статические функции от обычных функций отличаются тем, что в не статическую функцию неявно передается указатель this, который указывает на объект данного класса (в языке Smaltalk есть аналогичное понятие Self, которое, однако, не является указателем). Статические функции имеют смысл только в пределах самого класса, поэтому к ним имеет смысл обращаться с помощью несколько другой нотации: имя_класса::имя_стат_функции.
Механизм классов более элегантен, чем механизм модулей. Нам не нужна дополнительная обертка. Для того чтобы описать класс Stack достаточно написать:
struct Stack {
char* body;
int top;
…
};
В механизме классов в С++ есть несколько "изюминок", которые, с одной стороны, несколько усложняют реализацию и понимание, с другой стороны, решают некоторые наболевшие проблемы, которые не решаются с помощью модулей. Прежде всего, – это проблемы инициализации и, соответственно, уничтожения объектов. Если мы выберем динамическую реализацию стека, то возникает необходимость его удалить после завершения работы с ним. В языке Ада решалась проблема инициализации стека (с помощью параметризации), но не решалась проблема его удаления. Но для более сложных структур в Аде не всегда можно решить и проблему инициализации, поэтому приходилось писать некоторую процедуру Init, которая и занималась инициализацией. Однако, в этом случае, компилятор не сможет проконтролировать наличие инициализации, и нет никаких средств для выполнения этой процедуры автоматически. Также отсутствует механизм уничтожения ресурсов, взятых при инициализации. Проблема деструкции более насущная, потому что компилятор в принципе может отследить неинициализированные переменные (и выдать warning), и современные оптимизирующие компиляторы этим занимаются. Проблема статической разрешимости неинициализированных объектов алгоритмически не разрешима, но еще более сложна проблема уничтожения объектов.
Механизм классов в С++ хорош еще и тем, что в нем есть интегрированная в язык возможность автоматически управлять инициализацией и уничтожением объектов. Это делается посредством специальных функций-членов - конструкторов и деструкторов. Эти функции-члены, в отличие от обычных, имеют еще и дополнительную семантику. К специальным функциям членам относятся конструкторы, деструкторы и преобразования.
Конструкторы.
Пусть у нас есть класс X. Синтаксически, конструктор имеет то же самое имя X(…). У этой функции нет никакого возвращаемого значения (при этом, это не void-функция). И не нужно думать, что у этой функции возвращаемое значение int (это тип по умолчанию). В зависимости от видов параметров и, следовательно, от его семантики конструкторы делятся на четыре класса:
-
1. Конструкторы умолчания X();
-
2. Конструкторы преобразования X(T); X(T&); X(const T&);
-
3. Конструкторы копирования X(X&); X(const X&);
-
4. Остальные конструкторы
Первые три типа конструктора (каждый из них) имеют дополнительную семантику по сравнению с четвертым типом конструктора. Эта семантика связана с тем, чтобы компилятор автоматически вставлял действия по соответствующей инициализации. Страуструп написал, что конструктор – это то, что кусок памяти превращает в объект. Когда в языке Си мы делали malloc, то это не было инициализацией – это просто отведение куска памяти.
Инициализация может задаваться пользователем, и бывает системная (или стандартная) инициализация. Пользовательская инициализация – это те действия, которые заданы в теле соответствующего конструктора. Например, в случае структуры Stack, полное описание должно включать в себя отведение памяти.
struct Stack {
char* body;
int top;
int size;
};
Stack::Stack(int sz) { // Это конструктор преобразования
body = new char [size=sz]; // Ошибки не обрабатываем
top = 0;
};
Где же происходит инициализация? В данном случае инициализация только пользовательская, и никакой системной инициализации транслятор не вставляет, потому что у нас простое тело стека. Поскольку вызов конструктор вставляется автоматически, компилятор должен знать, какой параметр передать конструктору. Поэтому переменная типа Stack описывается так: Stack S(20). При этом объявлении и вызывается конструктор, который превращает кусок памяти в стек.
В С++ объекты могут размещаться в одном из трех типов памяти – статической, динамической и квазистатической. В какой момент будет выполнен конструктор стека, если стек описан как статическая переменная? Он должен быть выполнен в тот момент, как только объект размещен в памяти. Статические данные размещаются перед началом выполнения программы (функции main) и сразу же выполняются все конструкторы статических объектов. В квазистатической памяти все объекты инициализируются в момент входа в соответствующий блок.
При распределении динамической памяти в С++ уже нельзя обойтись стандартной библиотекой (функциями malloc и free), потому что нужно указать, что при выделении памяти под объект должен еще выполняться конструктор. Именно по этому в С++ появились зарезервированные слова new и delete. В С++ есть возможность переопределить динамическое распределение памяти в операторах new и delete. Соответственно, инициализация объекта будет выглядеть так: