Лабораторные работы по С/C++
Конкретные типы данных. Ортодоксальная каноническая форма класса
Конкретными типами данных называются типы, определяемые программистом. Их реализация может быть основана на других абстрактных или конкретных типах данных и примитивных типах языка ( например, int, char * ). Конкретные типы данных реализуются в виде классов, для них можно определять их имя, операторы и поведение. Объекты класса будут объявляться, присваиваться и передаваться в аргументах так же, как любая переменная С. Такой класс реализуется по особому образцу, в результате чего компилятор генерирует эффективный код создания, копирования и присваивания экземпляров класса.
Такую форму класса можно назвать ортодоксальной канонической формой класса.
«Каноническая» означает, что форма определяет систему правил, которые должны соблюдаться компилятором при
генерировании кода, а «ортодоксальная» - что форма непосредственно поддерживается самим языком. Познакомимся с
этой
идиомой на примере класса String.
Объявление класса:
class String {
public:
// переопределение оператора "+" для конкатенации
friend String operator+(const char*, const String&);
String operator+(const String&) const;
int length() const; // длина строки
String(); // конструктор по-умолчанию
String(const String&); // инициализация по другой строке
String& operator=(const String&); // присваивание
~String(); // деструктор
String(const char *); // инициализация по строке char *, пользовательский конструктор
private:
char *rep;
};
Определение класса:
// Конструктор по-умолчанию
String::String() {
rep = new char[1];
rep[0] = '\0';
}
String::String(const String& s) {
rep = new char[s.length() + 1];
// копирование данных из одной строки в другую
::strcpy(rep, s.rep);
}
String& String::operator=(const String& s) {
if (rep != s.rep) {
delete[] rep;
int len = s.length() + 1;
rep = new char[len];
::strcpy(rep, s.rep);
}
return *this; // результат – ссылка на данный объект
}
// Деструктор
String::~String() {
delete[] rep; // Очищаем память
}
String::String(const char *s) {
int len =::strlen(s) + 1;
rep = new char[len];
::strcpy(rep, s);
}
int String::length() const {
return ::strlen( rep );
}
String String::operator+(const String& s) const
{
char *buf = new char[s.length() + length() + 1];
::strcpy(buf, rep);
::strcat(buf, s.rep);
String retval( buf );
delete[] buf;
return retval; // возвращается новый объект
}
Наличие конструктора копирования необходимо для того, чтобы экземпляры класса могли передаваться в параметрах
функций
так же, как и обычные переменные. Например, рассмотрим класс С++, который использует динамические данные в
реализации:
class C {
//...
private:
int **v;
};
Если объект такого класса передать по значению в параметре функции, то при уничтожении кадра стека будет вызван
деструктор
копии объекта С, который уничтожит динамические данные объекта. Поэтому нужно либо всегда
передавать
объект С по ссылке, либо реализовать копирующий конструктор. Но т.к. пользователь класса С может не знать о том,
что
класс использует динамически выделяемые данные,
лучше застраховать его от ошибки, поместив в класс С копирующий конструктор.
Ортодоксальная каноническая форма класса лежит в основе большинства конкретных типов данных. Другие канонические
формы также строятся на ее основе.
Вообще, для произвольного класса С эта форма характеризуется присутствием следующих элементов:
- конструктора по умолчанию С::С();
- копирующего конструктора С::С(const C&));
- оператора присваивания С& operator=(const C&));
- деструктора C::~C();
Т.е. упрощенное объявление класса String может выглядеть так:
class String {
public:
String(); // конструктор по умолчанию
String(const String&); // копирующий конструктор
String& operator=(const String&); // присваивание
~String(); // деструктор
String(const char *); // инициализация по строке char *
int length() const; // длина строки
private:
char *rep;
};
Задание.
Реализовать класс комплексных чисел Complex, используя ортодоксальную каноническую
форму класса. Добавить пользовательские конструкторы для инициализации из примитивных типов С++, например из
double:
// r - real, действительная часть, i - image, мнимая часть
Complex(double r=0, double i=0) : r(r), i(i) {}
Добавить операторы сложения, вычитания, умножения на комплексное число, умножения на действительное число, отрицания. А также вывода в стандартный поток ostream, аналогично оператору вывода строки:
#include <iostream>
using namespace std;
class String {
...
friend ostream& operator<<(ostream& s, const String& str) {
s << str.rep; // Выводим содержмое строки в поток
return s; // Возвращаем поток
}
...
};
при этом вывод на консоль осуществляется так:
#include "String.h"
int main(int argc, char **argv) {
String a("this is string");
cout << a << endl; // Вывод строки как любого простого типа в поток
return 0;
}
Для комплексных чисел реализовать также функцию перевода в показательную форму.
2. «Перегрузка - переопределение семантики операторов и функций».
Перегрузка – это простой вид статического (времени компиляции) полиморфизма, позволяющий нескольким одноименным функциям существовать в одной области видимости. Перегрузка представляет собой удобную синтаксическую конструкцию, сокращающую код программы.
Преобразования типов, определяемые классом
Один из принципов ООП состоит в том, что определяемые пользователем типы должны иметь те же привилегии, что и встроенные типы. Явное преобразование типов выражения применяется тогда, когда неявное преобразование нежелательно или когда без него выражение недопустимо. Одна из задач С++ - интеграция абстрактного типа данных и встроенных типов. Для этого используют функцию-член, обеспечивающую явное преобразование:
(имя_типа) выражение
либо в функциональной записи (тогда тип должен быть выражаем как идентификатор):
имя_типа (выражение)
Конструктор с одним аргументом фактически представляет собой преобразование из типа аргумента к типу конструируемого класса:
string(const char *p) {
len = strlen(p);
s = new char[len+1];
strcpy(s,p);
}
Однако, пользователь не может добавлять конструкторы встроенных типов, таких как int. Может возникнуть необходимость в преобразовании из string в char *, что может быть выполнено с помощью определения специальной функции преобразования внутри класса string следующим образом:
operator const char*() const { return s; }
Первый модификатор const означает, что память, на которую ссылается возвращаемое значение, не должна
модифицироваться
за пределами класса, а второй – что эта функция не изменяет содержимого объекта String, для которого она
вызывается.
Такая функция преобразования должна быть нестатической функцией-членом без возвращаемого типа и с пустым списком
аргументов. Преобразование происходит неявно в выражениях присваивания, при передаче параметров функциям, и в
значениях, возвращаемых функциями. Оператор просто возвращает указатель на внутренние данные, находящиеся в
формате
С.
Это иллюстрирует идиому операторной функции класса.
Преобразующая функция-член в форме A::operator B() и конструктор в форме B::B(const A&) обеспечивают преобразование из типов объекта A в тип объекта B.
Использование одновременно конструктора и перегрузки операции приведения типа невозможно. Так как операции преобразования не имеют ни явных аргументов, ни возвращаемого значения, то они не могут быть перегружены, но может быть несколько операторов преобразования для различных типов. Операции преобразования наследуются и могут быть виртуальными.
Включение операторов преобразования в класс должно быть хорошо продумано с позиций проектирования. Слишком частое использование операторов преобразованияя повышает риск неоднозначных преобразований.
Перегружаемые операции
В С++ существуют две разновидности перегрузки: перегрузка в классе и глобальная перегрузка. Переопределение
смысла оператора, называемое перегрузкой оператора,
определяет типы в контексте операций, выполняемых с ними. В С++ пользовательские типы могут применяться так же,
как
встроенные типы благодаря как конкретным типам данных, так и перегрузке операторов.
Наиболее часто используемые операторы (функции-члены класса):
C& operator=(const C &c) {
if (c == *this) return *this;
...
return *this; // возвращается объект, для которого вызван оператор
}
C operator+ (const C &c) const {
...
C retval();
...
return retval; // возвращается новый объект
}
C operator* (const C &c) const {
...
C retval();
...
return retval;
}
C& operator ++() { // префиксная форма
...
return *this;
}
C& operator ++(int) { // постфиксная форма
...
return *this;
}
Если перегружаемый оператор определяется как дружественная функция или глобальная, то в параметрах указываются оба объекта:
friend C operator+(const C&, const C&);
Ключевое слово friend - спецификатор функции, который дает функции- не члену класса доступ к скрытым членам
класса.
Он
используется для того, чтобы выйти за строгие рамки типизации и сокрытия данных в С++.
Одна из причин их использования состоит втом, что некоторые функции нуждаются в привилегированном доступе более,
чем
к
одному классу. Вторая причина - friend-функция передает все параметры через список параметров, и значение
каждого из
них подчинено преобразованию, совместимому с назначением. Такие преобразования применяются к явно переданным
аргументам-классам и поэтому особенно полезны в случаях перегрузки оператора.
Глобальная перегрука операторов обеспечивает симметрию, которую другим образом организовать невозможно.
Рассмотрим
случай с глобальной перегрузкой функций operator+, дружественных для класса String:
class String {
friend String operator+(const char*, const String&);
friend String operator+(const String&, const char*);
...
};
String operator+(const char* s, const String& S) {
...
}
String operator+(const String& S, const char* s) {
...
}
При использовании этих объявлений будут работать обе операции суммирования:
String s; "abc"+s; s+"123";
Решение о выборе между представлением friend и функцией-членом обычно зависит от того, насколько необходимы и доступны операторы неявного преобразования. Явная передача аргумента, как в friend-функции, позволяет автоматическое его приведение.
Операция вызова функции
Операция вызова функции должна быть объявлена как нестатическая функция-член класса. Она позволяет пользователю определять число операндов. Например:
class C {
unsigned long operator()(int,int,double);
};
Оператор присваивания
Оператор присваивания = является, вообще говоря, бинарным оператором. Его объявление идентично любому другому бинарному оператру, за исключением:
- оператор должен быть нестатической функцией-членом; он не может быть объявлен за пределами класса;
- он не наследуется производными классами;
- он может быть сгенерирован по умолчанию компилятором, если не существует в классе.
Операторы, доступные для перегрузки
В стандарте С++03 для перегрузки доступны все арифметические, логические операторы, операторы сравнения, равенства, присваивания, операторы поразрядных операций, префиксные и постфиксные формы операторов приращения и декремента; могут быть перегружены операторы индексирования [] и обращения к функции (). Также могут быть перегружены оператор указателя класса -> и оператор указателя на член класса ->*. Возможна перегрузка new, delete.
Операторы, не доступные для перегрузки:
- выбор элемента - object.member
- указатель на элемент - object.*member
- разрешение области видимости - name::member
- условный оператор сравнения - с = (а > b) ? а : b
В новом стандарте "ISO/IEC 14883(2009): Programming Language C++" рассматривается возможность
перегрузки
операторов
. .* static_cast<> const_cast<> dynamic_cast<>
3. «Классы конвертов и писем»
В чистых объектно-ориентированных языках вроде Smalltalk переменные связываются с объектами на стадии выполнения.
Переменные в таких языках несут минимальный объем информации о типе, а компилятор почти не следит за
совместимостью
типов. Высокая степень гибкости, присущая таким языкам, приносит пользу при доработке программы и закладывает
основу
для более совершенных механизмов упраления памятью.
Модель типов С++ не обладает такой гибкостью из-за раннего связывания символических имен с адресами и из-за
ранней
проверки совместимости типов. Простейший способ компенсировать это обстоятельство – обращаться к объектам через
указатели. Но такое программирование порождает свои неудобства:
- усложняется управление памятью;
- возникает опасность появления «висячих указателей»;
- обращение к объектам через указатели затрудняет использование некоторых конструкций языка, особенно перегрузку операторов;
- неудобный синтаксис: vector[i] = *vector[i] * *m[i];
- система контроля типов С++ может заблокировать некоторые допустимые операции.
На практике часто требуется работать с группой взаимозаменяемых классов и интерпретировать все объекты как объекты одного класса. Виртуальные функции предоставляют такую возможность для указателей и ссылок, но указатели плохо защищены от злоупотреблений и не интегрируются с перегруженными операторами.
Вообще, нужно сделать так, чтобы объект автоматически применял свое умение выполнять различные операции. Для этого нужно решить следующие проблемы:
- объекты должны идентифицировать свой фактический класс на стадии выполнения (в результате инициализации или выполнения операции);
- объекты разных классов занимают разный объем памяти, изменение класса приводит к изменению размера объекта;
- связывание объектов с виртуальными функциями производится в процессе инициализации объекта и остается неизменным;
- после идентификации класса объект может сменить свою принадлежность; например, сохранять принадлежность объекта к комплексным числам после его обнуления бессмысленно.
Итак, нужно создать нежесткий механизм установления связи между объектом и его классом на стадии выполнения.
Рассмотрим его на примере классов чисел Number.
На базе Number строится иерархия числовых типов. Объекты таких классов упаковываются в «конверт»,
сохраняющий
идентичность величины (адрес), пока ее значение и/или тип изменяются с применением к ней различных операций.
Класс Number играет две роли: абстрактного базового класса для классов «писем» и «конверта», скрывающего
подробности
реализации. Основная логика управления памятью (в соответствии с идиомой подсчета ссылок) для объектов Number
сосредоточена в классе конверта, а семантика приложения размещается в классе «письма».
// для предотвращения рекурсии при вызове конструкторов
struct BaseConstructor { BaseConstructor(int=0) {} };
class Number {
public:
Number() { rep = new Real(0,0); }
Number(double d) { rep = new Real(d); }
Number(double r, double i) {
rep = new Complex(r,i);
}
Number operator=(Number &n) {
n.rep->refCnt++;
if(--rep->refCnt == 0) delete rep;
rep = n.rep;
return *this;
}
Number(Number &n) {
n.rep->refCnt++;
rep = n.rep;
}
virtual Number operator+(const Number &n) {
return rep->operator+(n);
}
virtual ostream& operator<<(ostream &s) {
return rep->operator<<(s);
}
virtual Number complexAdd(Number &n) {
return rep->complexAdd(n);
}
virtual Number realAdd(Number &n) {
return rep->realAdd(n);
}
void redefine(Number &n) {
if (--rep->refCnt == 0) delete rep;
rep = n;
}
...
protected:
Number(BaseConstructor) { refCnt = 1; }
private:
Number *rep;
short refCnt;
};
class Complex: public Number {
public:
Complex(double r, double i) : Number(BaseConstructor()) {
rpart = r; ipart = i;
refCnt = 1;
}
Number operator+(Number &n) { return n.complexAdd(*this); }
...
Number realAdd(Number &n) {
Number ret;
Complex *c1 = new Complex(*this);
Real *c2 = (Real)&n;
c1->rpart += c2->r;
ret.redefine(c1);
return ret;
}
Number complexAdd(Number &n) {
...
}
private:
double rpart, ipart;
};
Идиома конверт/письмо является фундаментом для большого количества других идиом, в частности на ней основана символическая каноническая форма, являющаяся альтернативой для ортодоксальной канонической формы. Символическая идиома предоставляет всю мощь динамического полиморфизма и служит для эмуляции символических парадигм в С++. При использовании этой альтернативной канонической формы для классов С++ код способен моделировать многие свойства символических языков программирования вообще. Подробное описание можно найти в книге «Advanced C++ programming. Styles and idioms», James O. Coplien ( «Программирование на С++», Дж. Коплиен).
4. «Имитация виртуальных конструкторов»
// Number.h
// #pragma once - препроцессорная директива чтобы файлы подключались ровно один раз
#pragma once
#include <iostream>
#include <math.h>
using namespace std;
class Number {
public:
Number() {}
virtual ~Number() {} // Виртуальный деструктор
virtual double value() const { return 0; }
virtual operator double() const { return 0; }
virtual ostream& out(ostream &s) { return s; }
virtual Number *copy() const = 0;
friend ostream& operator<<(ostream &s, Number &n) {
return n.out(s);
}
};
class Complex : public Number {
public:
Complex(double r=0, double i=0) : r(r), i(i) {}
Complex(const Complex &c) : r(c.r), i(c.i) {}
operator double() const { return ::sqrt(r*r+i*i); }
Number *copy() const {
Complex *ret = new Complex;
ret->r=r; ret->i=i;
return ret;
}
ostream& out(ostream &s) { s<<r<<"+j"<<i; return s; }
private:
double r, i;
};
class Imaginary : public Number {
public:
Imaginary(double i=0) : i(i) {}
Imaginary(const Imaginary &i) : i(i.i) {}
operator double() const { return ::abs(i); }
double value() const { return i; }
Number *copy() const {
Imaginary *ret = new Imaginary;
ret->i=i;
return ret;
}
ostream& out(ostream &s) { s<<'j'<<i; return s; }
private:
double i;
};
class Real : public Number {
public:
Real(double x=0) : r(x) {}
Real(const Real &x) : r(x.r) {}
operator double() const { return r; }
double value() const { return r; }
Number *copy() const {
Real *ret = new Real;
ret->r=r;
return ret;
}
ostream& out(ostream &s) { s<<r; return s; }
private:
double r;
};
class EnvNumber : public Number {
public:
EnvNumber(double=0, double=0);
EnvNumber(const EnvNumber& n) { realNumber=n.copy(); }
~EnvNumber() { delete realNumber; }
Number *copy() const { return realNumber->copy(); }
Number *transform() {
Number *ret=realNumber;
realNumber=0;
return ret;
}
public:
//объединение сигнатуры всех классов иерархии Number
double value() const { return realNumber->value(); }
operator double() const { return realNumber->operator double(); }
ostream& out(ostream &s) { return realNumber->out(s); }
private:
Number *realNumber;
};
EnvNumber::EnvNumber(double a, double b) {
if (b==0) realNumber = new Real(a);
else if (a==0) realNumber = new Imaginary(b);
else realNumber = new Complex(a,b);
}
main.cpp
#include "Number.h"
#include <vector>
Number* ReadNumber(vector<int> *v) {
vector<int>::iterator i = v->begin();
int a=*i; v->erase(i);
int b=*i; v->erase(i);
EnvNumber n(a,b);
return n.transform();
}
int main(int argc, char **argv) {
Number *n;
int nums[] = { 0,5, 2,0, 1,1 };
vector<int> v;
v.assign(nums, nums+6);
do {
n = ReadNumber(&v);
cout << typeid(*n).name() << ' ' << *n << endl;
}
while(!v.empty());
return 0;
}
+Конкретные типы данных. Ортодоксальная каноническая форма класса.
+Перегрузка - переопределение семантики операторов и функций.
+Классы конвертов и синглетные письма.
-Имитация виртуальных конструкторов.
Множественное наследование.
(Мультиметоды - только общие понятия.)
Шаблоны.