Семинар 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;
};

Пояснения:

Далее, файл 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);
}

И наконец создадим файл 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() возвращает длину строки.

Теперь определим наши новые методы в 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;
    }
}

Стоит отметить, что так же, как и в случае с конктрукторами, класс может содержать несколько методов с одинаковыми именами, но разными наборами аргументов.

Воспользуемся нашими новыми методами. В 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

Добавим определения в 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 показана форма инициализации полей до исполнения тела конструктора (выглядит более лаконично и может использоваться для инициализации константных полей)

Теперь воспользуемся нашим итератором:

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;
};