Семинар 2 (11.09.2015)
Классы и объекты
Важно понимать (и уметь объяснять) разницу между понятиями "класс" и "объект". Также важно понимать разницу между понятиями "метод класса" и "поле класса".
Класс для работы со строками
Напишем свою собственную реализация класса "Строка".
Для того, чтобы не путать наш класс со строкой из стандартной библиотекой,
назовём наш класс Str
.
Исходный код программы будет включать в себя три файла:
- str.h
- str.cpp
- main.cpp
Начнём с заголовочного файлаstr.h
, который содержит объявление класса Str
:
#pragma once
class Str { // A
public: // B
Str(); // C
Str(const char * s); // D
~Str(); // E
private: // F
char * data; // G
int len;
};
Пояснения:
-
Строка A: объявление нового класса с именем
Str
-
Строка B: модификатор доступа
public
: все методы, поля и конструкторы объявленные после этой строки являются "публичными", то есть доступными "снаружи" относительно класса -
Строка C: Объявление конструктора. Конструктор - это специальный метод для инициализации ("конструирования") объекта - код конструктора будет вызван при создании объекта данного класса. Данный конструктор не принимает аргументов (и, как можно предположить, создаёт пустую строку).
-
Строка D: Класс может иметь несколько конструкторов, различающихся набором аргументов. Данный конструктор создаёт строку на основе строки C (типа
const char *
) -
Строка E: Деструктор, то есть специальный метод для уничтожения объекта. Основная задача деструктора - "освободить" ресурсы, "захваченные" в процессе существования объекта (например, память, файловые дескрипторы и так далее).
-
Строка F: модификатор доступа
private
: все поля, методы и конструкторы, объявленные после этой строки являются "приватными", то есть доступными только из других методов данного класса. -
Строка G: объявление полей класса. В данном случае это поле
data
типаchar *
и полеlen
типаint
.data
будет хранить содержимое строки, аlen
- её длину.
Далее, файл str.cpp
, содержащий собственно код - определения методов класса Str
:
#include "str.h" // A
#include <cstring> // B
#include <cstdlib>
Str::Str() { // C
data = nullptr; // D
len = 0;
}
Str::Str(const char * s) { // E
data = strdup(s); // F
len = strlen(data);
}
Str::~Str() { // G
free(data);
}
-
Строка A: подключение заголовочного файла
str.h
. В C++ общепринятой практикой является создание для каждого класса в программе собственных заголовочного и исходного файла, имена которых совпадают с именем класса (или именем класса в нижнем регистре). Например, для классаStr
мы создали два файла:str.h
иstr.cpp
. Далее в первой строке cpp-файла необходимо подключить соответствующий h-файл - это будет признаком хорошего стиля :) -
Строка B: подключение библиотек языка C. Заметьте, что в C++ имена заголовочных файлов библиотек называются
cstring
иcstdlib
, а неstring.h
иstdlib.h
(хотя второй вариант тоже можно использовать) -
Строка C: Определение конструктора
Str
без аргументов.Str
до знака::
- это имя класса,Str
после знака::
- это имя конструктора,()
- пустой список аргументов. -
Строка D: тело конструктора, в котором мы инициализируем поля класса значениями по умолчанию.
nullptr
- нулевой указатель, аналогичныйNULL
из C, но более безопасный (начиная с C++11). -
Строка E: определение второго конструктора, принимающего аргумент
char*
. -
Строка F: тело второго конструктора. Мы используем библиотечную функцию C
strdup
для копирования строки C. Важно отметить, что мы именно копируем строку, а не сохраняем указатель. -
Строка G: определение деструктора. В отличие от конструктора, деструктор никогда не принимает аргументы. Внутри деструктора мы освобождаем память, выделенную в конструкторе, используя функцию
free
.
И наконец создадим файл main.cpp
, в котором используем нашу новую строку:
#include "str.h"
void work() {
Str x; // A
Str y("hello world!"); // B
}
int main() {
work();
return 0;
}
Здесь в строке A мы объявляем переменную x
типа Str
.
При этом вызовется конструктор без аргументов, который сделает x
пустой строкой.
В строке B объявляется переменная y
типа Str
, при этом вызовется второй конструктор
с аргументом "hello world!"
и y
будет содержать копию строки "hello world!"
При выходе из функции переменные x
и y
перестанут существовать, и будут вызваны
деструкторы x
и y
.
Методы класса
Нетрудно заметить, что в текущем виде класс Str
не позволяет сделать что-нибудь действительно полезное,
потому чо в нём пока нет методов.
Самое время добавить немного методов. В заголовочный файл добавим следующее:
#pragma once
class Str {
public:
Str();
Str(const char * s);
~Str();
int size() const; // H
char get(int index) const; // I
void set(int index, char c); // J
private:
char * data;
int len;
};
Здесь мы объявили три метода: size
, get
и set
.
* H: декларация методов похожа на декларацию функций: возвращаемое значение (int
),
имя метода (size
), список аргументов (в данном случае пустой - ()
).
Стоит отметить наличие модификатора const
, который говорит о том, что метод является константным,
то есть не изменяет состояние объекта (то есть состояние полей объекта), для которого он вызван.
Метод size()
возвращает длину строки.
-
I: метод
get(...)
, который возвращает символ строки в позицииindex
. -
J: метод
set(...)
, который устанавливает символc
в позицииindex
. Так как метод не является константным, модификатораconst
у него нет.
Теперь определим наши новые методы в str.cpp
:
// ...
int Str::size() const { // H
return len;
}
char Str::get(int index) const {
if (index >= 0 && index < len) {
return data[index];
}
return '\0'; // J
}
void Str::set(int index, char c) {
if (index >= 0 && index < len) {
data[index] = c;
}
}
-
H: определения методов похожи на определения конструкторов (и на определения обычных функций):
int
- возвращаемое значение,Str
- имя класса,size
- имя метода,()
- список аргументов- модификатор
const
обязателен, если он присутствовал в объявлении метода (в h-файле). Метод сconst
и метод безconst
- это два разных метода.
-
J: вообще попытка получить символ за границами строки в методе
get
является ошибкой, но пока мы просто вернём нулевой символ ('\0'
). С методомset
- аналогично.
Стоит отметить, что так же, как и в случае с конктрукторами, класс может содержать несколько методов с одинаковыми именами, но разными наборами аргументов.
Воспользуемся нашими новыми методами. В main.cpp
:
#include <iostream>
#include "str.h"
int main() {
Str x("hello world!");
x.set(0, 'H');
for (int i = 0; i < x.size(); i++) {
std::cout << x.get(i);
}
std::cout << std::endl;
}
Рекомендую ознакомиться с методами класса str::string
из стандартной библиотеки,
документацию можно посмотреть здесь.
Оператор вывода в поток
Одной из особенностей C++ при работе с вводом/выводом является использование потоков.
Определим оператор вывода в поток для класса Str
.
Для этого нам понадобится вспомогательный метод put
,
который будет записывать объект в поток.
#pragma once
#include <iostream> // K
class Str {
public:
Str();
Str(const char * s);
~Str();
int size() const;
char get(int index) const;
void set(int index, char c);
void put(std::ostream & out) const; // L
private:
char * data;
int len;
};
std::ostream & operator<<(std::ostream & out, const Str & str); // M
-
K: подключаем
iostream
для работы с потоками. -
L: вспомогательный метод
put
-
K: объявление оператора записи в поток. Следует отметить, что поток
out
передаётся по ссылке, а строкаstr
по константной ссылке. (Почему?) Также следует отметить, чтоoperator<<
является в данном случае обычной функцией, а не методом классаStr
.
Добавим определения в str.cpp
:
// ...
void Str::put(std::ostream & out) const {
out << '"' << data << '"';
}
std::ostream & operator<<(std::ostream & out, const Str & str) {
str.put(out);
return out;
}
Здесь всё достаточно просто.
Хотя можно задать вопрос: зачем понадобился дополнительный метод put
?
Примечание: можно обойтись без вспомогательного метода, если воспользоваться помощью друзей (friendship), но этот метод имеет свои недостатки, поэтому (пока) имеет смысл от него воздержаться.
Собственно, теперь мы можем использовать потоки вывода вместе с нашим классом Str
:
int main() {
Str x("hello world!");
std::cout << x << std::endl;
}
Итераторы
Если посмотреть на методы std::string, то можно обнаружить
методы begin и end,
которые возвращают объекты класса std::string::iterator
.
Вообще, концепция итераторов - специальных объектов, позволяющих получить доступ к некоторому элементу коллекции
(а также к соседних элементам) - широко распространена в стандартной библиотеке C++.
В документации можно найти пример типичного использования итераторов:
std::string str ("Test string");
for ( std::string::iterator it=str.begin(); it!=str.end(); ++it)
std::cout << *it;
std::cout << '\n';
Как нетрудно догадаться, здесь последовательно распечатываются символы строки.
Попробуем реализовать простейший итератор для класса Str
. В заголовочный файл добавим:
class Str {
public:
// ...
class iterator {
public:
iterator(char * _data, int _len, int _pos);
iterator(const iterator & it) = default;
~iterator() = default;
iterator & operator+=(int i);
iterator operator+(int i) const;
iterator & operator++();
iterator operator++(int);
char & operator*();
char * operator->();
private:
char * str_data;
int str_len;
int position;
};
iterator begin();
iterator end();
private:
char * data;
int len;
};
Как видно, итератор будет указывать на некоторую позицию в строке. Реализация:
Str::iterator::iterator(char * _data, int _len, int _pos) :
str_data(_data),
str_len(_len),
position(_pos)
{
}
Str::iterator & Str::iterator::operator+=(int i) {
this->position += i;
return *this;
}
Str::iterator & Str::iterator::operator++() {
(*this) += 1;
return *this;
}
Str::iterator Str::iterator::operator++(int) {
Str::iterator it(*this);
(*this) += 1;
return it;
}
char & Str::iterator::operator*() {
return str_data[position];
}
char * Str::iterator::operator->() {
return str_data + position;
}
Str::iterator Str::begin() {
return Str::iterator(data, len, 0);
}
Str::iterator Str::end() {
return Str::iterator(data, len, len);
}
Комментарии:
* В конструкторе Str::iterator::iterator
показана форма инициализации полей до исполнения тела конструктора
(выглядит более лаконично и может использоваться для инициализации константных полей)
-
this
- это указатель на самого себя. Как можно догадаться, имеет типStr*
. Операторы+=
и префиксный оператор++
возвращают ссылку на самого себя. -
operator++(int)
- это постфиксный оператор++
. Вспомните разницу между++i
иi++
. Постфиксный итератор делает инкремент внутри себя, но возвращает копию старого итератора. -
оператор
*
возвращает ссылку на элемент строки. В данном случае синтаксис аналогичен разыменованию указателей в C.
Теперь воспользуемся нашим итератором:
int main() {
Str str("hello world!");
for (Str::iterator it = str.begin(); it != str.end(); ++it) {
std::cout << *it;
}
std::cout << std::endl;
}
Компилируем и ...
error: no match for ‘operator!=’ (operand types are ‘Str::iterator’ and ‘Str::iterator’)
for (Str::iterator it = str.begin(); it != str.end(); ++it) {
^
Мы забыли про операторы ==
и !=
для итераторов. Эти операторы должны иметь следующую сигнатуру:
class iterator {
// ...
bool operator==(const iterator & it) const;
bool operator!=(const iterator & it) const;
};
Как правило, в таких случаях реализуют один оператор, а второй реализуют через отрицание первого.
Если сделать всё правильно, то наш код скомпилируется и выведет hello world!
.
А теперь - бонус! Для класса, имеющего итераторы начиная с C++11 можно написать следующий код:
int main() {
Str str("hello world!");
for (char c : str) {
std::cout << c;
}
std::cout << std::endl;
}
Этот код аналогичен приведённому выше "итерированию" коллекций через итераторы, только намного проще :)
Создание объектов
Допустим, у нас есть класс A
, у которого есть метод std::string getData() const
.
Как мы можем создать объект класса A
?
Начнём с простого:
int main() {
{
A a; // A
std::cout << a.getData() << std::endl;
} // B
}
В строке A вызовется конструктор класса A
без аргументов,
в строке B вызовется деструктор - объект a
перестанет существовать.
A b("hello");
std::cout << b.getData() << std::endl;
Здесь будет вызван конструктор с одним аргументом типа const char *
(или типа std::string
, или const std::string &
или std::initializer_list<std::string>
- в общем, тут возможны варианты :) ).
Объект b
будет также автоматически уничтожен при выходе из текущей области видимости.
A * c = new A(); // A
std::cout << c->getData() << std::endl;
delete c; // B
Инициализация указателя на объект класса A
.
В строке A будет вызван конструктор (без аргументов).
Важно помнить, что объекты созданные оператором new
должны быть
явно удалены оператором delete
(строка B), иначе - утечка памяти.
A * d = new A("world");
std::cout << d->getData() << std::endl;
delete d;
Всё то же самое, за исключением вызова конструктора с аргументами.
Теперь к самому интересному. C++11 предлагает унифицированную форму инициализации (а также списки инициализации). Выглядит следующим образом:
A e{};
std::cout << e.getData() << std::endl;
A f{"hello", "world"};
std::cout << f.getData() << std::endl;
A * g = new A{"foo", "bar"};
std::cout << g->getData() << std::endl;
delete g;
Вроде бы ничего существенного, просто (...)
поменялись на {...}
.
С одной стороны, подобный синтаксис был ещё в C - там он применялся для инициализации массивов:
int arr[] = {1, 2, 3};
С другой стороны, в C++11 возможности фигурных скобочек существенно расширились - унифицированная форма инициализации может применяться не только для объектов, но и для примитивных типов:
int x{13};
std::cout << x << std::endl;
и для коллекций, таких как вектор:
std::vector<std::string> vs{"one", "two", "three"};
for (auto s : vs) {
std::cout << s << std::endl;
}
или ассоциативный массив:
std::map<std::string, int> mp = {
{"one", 1},
{"two", 2},
{"three", 3}
};
for (auto pair : mp) {
std::cout << pair.first << " => " << pair.second << std::endl;
}
И напоследок, приведу код класса A, чтобы можно было запустить приведённые примеры:
class A {
public:
A() : A{""} {
std::cout << "A1\n";
}
A(const std::string & s) : data{s} {
std::cout << "A2\n";
}
A(const std::initializer_list<std::string> & list) {
std::cout << "A3\n";
for (auto i: list) {
data += i + " ";
}
}
~A() {
std::cout << "~A()\n";
}
std::string getData() const {
return data;
}
private:
std::string data;
};