5.1. Классы - StriderAJR/StudentCpp GitHub Wiki

Класс и объект

Класс - это пользовательский тип данных. Класс не хранит никаких данных, это лишь общее описание того, какие данные и как будут храниться в памяти. Еще класс называют абстракицей, чем-то общим, универсальным, описательным.

Класс состоит из:

  • Полей - переменных для хранения данных.
  • Методов - функций, которые может выполнять экземпляр класс.

Объект - переменная типа данных класс. Объект хранит какие-то конкретные данные. Еще объект называют конкретным воплощение (сущностью) класса.

Создадим тип данных "Человек" - Human

#include <iostream>
#include <ctime>

using namespace std;

namespace Classes
{
class Human
  {
      
    unsigned long long passportNumber;
     
  public:
      int height; // Рост
      int weight; // Вес
      char* name; // Имя

      void setPassport(int height, int weight, const char* n)
      {
          
          this->height = height;
          this->weight = weight;
          name = new char[strlen(n)];
          strcpy(name, n);

          int r = rand();
          passportNumber = 1111111111 + (r * 9999999999) / RAND_MAX;
      }

      unsigned long long getPassportNumber()
      {
          return passportNumber;
      }
  };

  void main()
  {
      
      srand(time(nullptr)); 

      Human jack; 
      Human alex; 
              
      jack.setPassport(170, 80, "Jack Smith");
      alex.setPassport(170, 80, "Alex Ranger");

      cout << "Jack's passport: " << jack.getPassportNumber() << endl;
      cout << "Alex's passport: " << alex.getPassportNumber() << endl;
   
  }

} 

По умолчанию все элементы класса скрыты от посторонних глаз.

Чтобы сделать "внутренности" класса доступными для работы за его пределами нужно использовать "модификатор доступа" public , таким образом мы говорим, все что написано ниже этого слова - публичное, доступно всем для обозрения и использования.

       public:
        int height; // Рост
        int weight; // Вес
        char* name; // Имя

Обратите внимание, что параметры height и weight имеют такое же имя как поля класса. Кстати, раз класс - это коробка с переменными и методами, то сам класс имеет доступ до всех своих "внутренностей" ВСЕГДА.

        this->height = height;
        this->weight = weight;
        name = new char[strlen(n)];
        strcpy(name, n);

При получении паспорта случайным образом генерируется его номер. Номер паспорта состоит из 4 цифр серии 1111 - 99999 и 6 цифр номера 111111 - 999999. Значит, нам нужно минимальное число 1111111111 и максимально 9999999999. Только rand() умеет генерировать строго определенный диапазон чисел. Значит, поможем ему попасть в нужный нам диапазон.

        int r = rand();
        passportNumber = 1111111111 + (r * 9999999999) / RAND_MAX;

Сначала проинициализируем генератор псведо-случайных чисел. Чтобы при каждом запуске генератор не выдавал одни и те же "случайные" значени, произведем скорпим генератору текущее время системы. time - возвращает текущее время в системе и сохраняет его в переменную-параметр. Знать время нам не нужно, поэтому передаем в time параметром nullptr, чтобы нигде не сохранять результат.

       srand(time(nullptr)); 

Создадим переменную типа данных Human:

        Human jack; // Знакомьтесь - Джек
        Human alex; // Знакомьтесь - Алекс

Джек и Алекс только что родились - мы создали объекты. Теперь им нужно сходить в паспортный стол и получить паспорт.

        jack.setPassport(170, 80, "Jack Smith");
        alex.setPassport(170, 80, "Alex Ranger");

        cout << "Jack's passport: " << jack.getPassportNumber() << endl;
        cout << "Alex's passport: " << alex.getPassportNumber() << endl;

Human - это класс. Он описывает, что есть у каждого человека: характеристики и действия.

jack и alex - объекты, конкретные представители класса Human.

Инкапсуляция

Зачем нужны публичные и приватные поля? (Кстати, есть еще модификатор protected, но о нем немного позже)

Считается, что вне класса находится жестокий и злой мир, а класс - маленький и беззащитный. Чтобы защитить свой нежный внутренний мир, класс прячет от внешнего мира все свои "потроха", чтобы кто-то случайно или намеренно не сломал информацию внутри класса. Такой механизм называется ИНКАПСУЛЯЦИЕЙ

Инкапсуляция - сокрытие сложности и внутренней структуры класса.

Если серьезно, то класс представляется в виде "черного ящика": какой алгоритм скрыт в нем, как класс работает, какие поля в нем есть - недопустимо знать пользователю, а тем более иметь возможость изменять информацию в классе напрямую. Все это делается из рассчета обспечения безопасности. По принципу инкапсуляции принято все поля делать приватными, а доступ к полям организовывать с помощью методов.

#include <iostream>
#include <sstream>
using namespace std;

namespace Encapsulation
{
    class Server
    {
        short ipAddress[4] = { 0, 0, 0, 0 };

    public:
        void setIpAddress(short array[])
        {
            if (ipAddress[0] != 0 || ipAddress[1] != 0 || ipAddress[2] != 0 || ipAddress[3] != 0)
            {
                cout << "Ты кто такой, чтобы менять адрес у сервера?! Гуляй отсюда." << endl;
                return;
            }

            for (int i = 0; i < 4; i++)
                if (array[i] < 0 || array[i] > 255)
                {
                    cout << "IP адрес некорректен!" << endl;
                    return;
                }
            for (int i = 0; i < 4; i++)
                ipAddress[i] = array[i];
        }
	
        char* getIpAddress()
        {
           
            stringstream sout; 
            sout << ipAddress[0] << "." << ipAddress[1] << "." << ipAddress[2] << "." << ipAddress[3] << '\0';

            char* ipString = new char[strlen(sout.str().c_str())];
            strcpy(ipString, sout.str().c_str());

            return ipString;
        }
    };

    void main()
    {
        short ip[4]{ 192, 168, 0, 1 };

        Server s;
        s.setIpAddress(ip);

        cout << s.getIpAddress() << endl;

        
        short ip2[4]{ 192, 168, 0, 100 };
        s.setIpAddress(ip); 

    }
} 

Метод, который позволяет задать значение поля, называют сеттером (от англ. setter).

  
       void setIpAddress(short array[])
       {
           // Если ip адрес уже был присвоен серверу...
           if (ipAddress[0] != 0 || ipAddress[1] != 0 || ipAddress[2] != 0 || ipAddress[3] != 0)
           {
               // то менять его уже нельзя!
               cout << "Ты кто такой, чтобы менять адрес у сервера?! Гуляй отсюда." << endl;

               return;
           }

ВАЖНОЕ ЗАМЕЧАНИЕ! Т.к. на данном этапе обучения, обработка исключительных ситуаций (ошибок и т.д.) еще темный лес, то сообщения об ошибках выводятся прямо в консоль, НО!!! Работать с интерфейсом (консолью, например) внутри класса считается очень плохим тоном! Но пока... Эх, придется делать так. Иначе мозг у кого-то может взорваться.

Внутри сеттера часто помещают проверки на корректность данных.

            for (int i = 0; i < 4; i++)
                // проверяем, чтобы все части ip адреса лежали в диапазоне от 1 до 255
                if (array[i] < 0 || array[i] > 255)
                {
                    cout << "IP адрес некорректен!" << endl;
                    return;
                }

            // Если адрес корректен, то сохраняем его
            for (int i = 0; i < 4; i++)
                ipAddress[i] = array[i];

Метод, который позволяет получить значение поля, называют геттером (от англ. getter).

Все любят потоки! Поток работы со строкой. Записываем в него данные как в обычный поток.

            stringstream sout;
            sout << ipAddress[0] << "." << ipAddress[1] << "." << ipAddress[2] << "." << ipAddress[3] << '\0';

Чтобы извлечь данные из потока: sout.str() вернет строку типа данных string , если придерживаться правила не использовать, то что сам не можешь написать, то чтобы из string получить char* вызываем метод c_str()

	 char* ipString = new char[strlen(sout.str().c_str())];
         strcpy(ipString, sout.str().c_str());

         return ipString;

Попробуем изменить ip адрес еще раз

        short ip2[4]{ 192, 168, 0, 100 };
        s.setIpAddress(ip);

А вот фиг вам! Там стоит защита.

ИТОГ:

  1. Инкапсуляция помогает скрыть сложность класса (пользователя не волнует, как мы храним данные внутри класса)

  2. Инкапсуляция обеспечивает уровень безопасности доступа к данным

  3. Инкапсуляция предоставляет механизм для безопасного изменения данных (проверки на корректность данных и т.д.), вместо того, чтобы писать s.ipAddress = ...

Жизненный цикл объекта, конструктор, деструктор

Принципы ООП старались как можно более приблизить реальную жизнь к программным моделям. Поэтому у объектов есть так называемый "жизненный цикл". Объекты рождаются, живут, выполняют свою роль, а затем умирают. Когда объект "рождается" вызывается специальный метод класса - конструктор. Когда объект "умирает" вызывается специальный метод класса - деструктор.

class Human
    {
        int height;
        int weight;

    public:
        Human()
        {
            cout << "Я родился! И ничего о себе не знаю." << endl;
        }

        Human(int h)
        {
            height = h;
            cout << "Я родился! И я знаю свой рост! " << height << " см" << endl;
        }

        Human(int h, int w) : height(h), weight(w) 
        {
            cout << "Я родился! И я знаю свой рост и вес! " << height << " см " << weight << " кг" << endl;
        }

        ~Human()
        {
            cout << "RIP" << endl;
        }
    };

    void main()
    {
         Human* temp;
         { 
            Human h1;
            Human h2(54);
            
            Human* h4 = new Human(50); 
            temp = h4; 
         } 
       
         Human h3(48, 3);
         
         delete temp;      
      
        Human* array1 = new Human[5]; 
        delete[] array1; 

        Human* array2 = new Human[5];
        delete array2; 
      
    }
}

Конструктор - метод класса, который имеет такое же имя, как и сам класс. Конструктор НЕ ИМЕЕТ возвращаемого значения, потому что результата работы у конструктора - нет. Он что-то делает и все.

     Human()
        {
            cout << "Я родился! И ничего о себе не знаю." << endl;
        }

Конструктор можно перегружать (может существовать несколько конструкторов с разными параметрами)

    Human(int h)
        {
            height = h;
            cout << "Я родился! И я знаю свой рост! " << height << " см" << endl;
        }

Если лениво писать внутри конструктора height = h; weight = w; то можно использовать краткую версию инициализатора

        Human(int h, int w) : height(h), weight(w) // <-- вот она, после ":"
        {
            cout << "Я родился! И я знаю свой рост и вес! " << height << " см " << weight << " кг" << endl;
        }

Деструктор - метод класса, который имеет такое же имя, как и сам класс с тильдой в начале. Деструктор также НЕ ИМЕЕТ возвращаемого значения

        ~Human()
        {
            cout << "RIP" << endl;
        }

Когда блок кода завершился, то переменные h1 и h2 были уничтожены, а перед тем как их "убили" вызвался деструктор, Но удалятся только статические объекты. Обратите внимание, что надпись RIP на данном этапе появилась только 2 раза.

Human* temp;
         { // <-- начало блока кода
            Human h1;
            Human h2(54);
            
            Human* h4 = new Human(50); // Это динамический объект
            temp = h4; // Сохраним адрес, чтобы не потерять
         } // <-- конец блока кода (все локальные переменные будут удалены)

Создаем новый объект. Он будет существовать до конца программы

    Human h3(48, 3);

А вот сейчас мы "убьем" динамический объект

  delete temp; // Сейчас вызовется деструктор

ИТОГ:

  • Конструктор вызывается сразу после создания объекта.
  • Делать конструктор может ВСЕ ЧТО УГОДНО. Но чаще всего его используют для начальной инициализации полей класса.
  • Деструктор вызывается непосредственно перед удалением объекта.
  • Делать деструктор может ВСЕ ЧТО УГОДНО. Но чаще всего его используют для высвобождения динамической памяти внутри класса.

А теперь вернемся к вопросу важности delete и delete[]. Наконец-то мы можем "почуствовать" в чем же важность этой разницы

        Human* array1 = new Human[5]; // 5 раз вызвался конструктор
        delete[] array1; // 5 раз вызвался деструктор

А теперь сделаем неправильно

       Human* array2 = new Human[5];
       delete array2; // Деструктор вызовется 1 раз, а затем... 

А затем в лучшем случае будет ошибка, в худшем - непредвиденное поведение.

Почему ошибка это лучший случай? Потому что лучше знать, в каком месте в коде вы накосячили, чем программа проскочит это место, а потом что-то пойдет не так в совершенно неожиданном месте.

Итак, почему же деструктор вызовется только один раз?

Когда мы выделяем память под объект или массив с помощью new и new[] соответственно на самом деле компилятору без разницы под массив выделяется память или нет, при выделении память просто вычисляется кол-во байт для резервирования, находится участок подходящего размера, он помечается как занятый, а перед этим участком записываются метаданные - размер занятого участка. Все.

Разницы между new и new[] с точки зрения памяти никакой. Но допустим, у нас массив не простых типов данных, а объектов. И у этих объектов есть кастомный деструктор, который нужно запускать. Если вызвать освобождение памяти с помощью delete, то память-то высвободится вся, потому что ее размер записан в метаданных, но delete будет считать, что объект-то в памяти всего один и лежит там, где указатель ему сказал, а значит деструктор вызывать нужно только один раз!

Но если деструктор вызвался только у одного объекта, за остальными остался "мусор" в памяти... В общем, предсказать что произойдет дальше в программе нельзя. Взорваться не взорвется, но ничем хорошим тоже вряд ли закончится.

Ошибка же может и не всплыть. Но если всплывет, то программа просто увидит повреждение кучи. Повезло. А может и не повезти.

Если же вызывать освобождение через delete[], то delete[] проанализирует, сколько объектов в массиве примерно так:

SIZE / sizeof(T)

SIZE - это размер всей зарезервированной памяти под массив

T - тип данных объекта, который хранится в массиве, в нашем примере это был Human

Узнав, что там лежит больше 1 объекта, delete[] уже начинает стучаться к каждому и вызывать деструктор.

Теперь вам понятно, когда перепутать delet и delete[] может быть опасно для жизни. Всегда используйте правильный вариант команды освобождения памяти.

Конструктор копирования

Внимание: идут постоянные отсылки к теме "Передача параметров по значению и ссыле". Убедитесь, что хорошо усвоили эту тему.

Мы уже множество раз рассматривали ситуации, что переменные копируются в памяти без нашего участия. Например, при передаче и возврате параметров в/из ф-ции. Все примеры, которые разбирались в той теме, были построены на простейших типах данных: int, float и т.д. При побайтовом копировании (а именно так программа копирует все данные - побайтого) никаких проблем с простыми типами данных возникнуть не может. Но что если параметром при передаче по значени будет объект? "Ну и что," - скажете вы, - "Пусть себе также копируется побайтово. В чем проблема?" А сейчас посмотрим на примере, в чем же будет проблема.

#include <iostream>
#include <fstream>
#include <cstdlib>
#include <cstdio>
#include <string.h>

using namespace std;

namespace ClassCopyConstructor
{
  class StudentDummy
  {
     char* fio = nullptr;
  public:
     StudentDummy(const char* fio)
     {
        setFio(fio);
     }
     ~StudentDummy()
     {
        delete[] fio;
     }
     void setFio(const char* fio)
     {
        if (this->fio != nullptr) 
           delete[] this->fio;
        this->fio = new char[strlen(fio)+1];
        strcpy(this->fio, fio);
     }
     const char* getFio()
     {
        return fio;
     }
  };

  void func(StudentDummy student)
  {
     student.setFio("another fio");
  }

  class Student
  {
     char* fio = nullptr;
  public:
     Student(const char* fio)
     {
        setFio(fio);
     }
     Student(Student& anotherStudent)
     {
        cout << "Copy constructor called" << endl;
        setFio(anotherStudent.getFio()); 
     }
     ~Student()
     {
        delete[] fio;
     }
     void setFio(const char* fio)
     {
        if (this->fio != nullptr)
           delete[] this->fio;
        this->fio = new char[strlen(fio) + 1];
        strcpy(this->fio, fio);
     }
     const char* getFio()
     {
        return fio;
     }
  };

  void func(Student student)
  {
     student.setFio("another fio");
  }

   void main()
   {
      StudentDummy st("Ivanov Ivan Ivanovich");
      func(st);
      cout << "StudentDummy fio = " << st.getFio() << endl;

      Student good("Ivanov Ivan Ivanovich");
      func(good);
      cout << "Student fio = " << good.getFio() << endl;

      Student st2 = good;
   }
}

Создам простейший класс, Студент.

  class StudentDummy
  {
     char* fio = nullptr;
  public:
     StudentDummy(const char* fio)
     {
        setFio(fio);
     }
     ~StudentDummy()
     {
        delete[] fio;
     }
     void setFio(const char* fio)
     {
        if (this->fio != nullptr) 
           delete[] this->fio;
        this->fio = new char[strlen(fio)+1];
        strcpy(this->fio, fio);
     }
     const char* getFio()
     {
        return fio;
     }
  };

Элементарный класс, всего лишь с одним полем. Конструктор как параметр принимает константную строку, в которой будет записано ФИО студента. Под это ФИО выделяется память в объекте и туда копируются данные. Все просто и знакомо.

В чем подвох?

А теперь создадим ф-цию, которая как параметр принимает студента и меняет его ФИО.

  void func(StudentDummy student)
  {
     student.setFio("another fio");
  }

И что? Ведь параметр в ф-ции передан по значению. В памяти создалась копия, работаем мы с копией, ФИО поменялось у копии. После завершения ф-ции копия уничтожилась, а исходные данные остались без изменений. В чем проблема?

Допустим, вызов ф-ции был такой:

   Student st("Ivanov Ivan Ivanovich");
    func(st);
    

Давайте посмотрим на состояние памяти до и после вызова ф-ции. До вызова ф-ции был создан студент, в конструкторе выделяется память под fio , туда записывается ФИО и указатель сохраняется в классе. Alt  Texr

Что же произойдет при вызове ф-ции.

Во-первых, создастся копия студента, потому что параметр передается по значению. Копирование будет побайтовое. А значит память будет в таком состоянии: Alt  Texr

Побайтово скопировался студент. В студенте внутри хранится только указатель на область в памяти с символами ФИО. Копия содержит только адрес данных, но не сами данные. И адрес оказался тот же, что у оригинала. А вот сами данные в памяти как были одни, так и остались. И получается, что один и тот же указатель разделили 2 объекта...

А внутри ф-ции мы меняем ФИО у копии. Если заглянуть внутрь метода setFio, то мы увидим такой код:

     if (this->fio != nullptr) 
        delete[] this->fio;
     this->fio = new char[strlen(fio)+1];
     strcpy(this->fio, fio);

Этот код удалит память по старому адресу, выделит новую память и сохранит новый адрес в поле fio. И вот что получится после выполнения всей ф-ции func: Alt  Texr

Память по адресу 0xFFFA1 была высвобождена в худшем случае (чаще всего) еще и "затерта" мусором. В копию student в поле fio был сохранен адрес нововыделенной памяти, в которую и записана новая строка "another fio".

Ф-ция func завершилась. Копия student уничтожается, но память уже безвозвратно испорчена. Alt  Texr

С такой ситуацией нельзя мириться и нельзя ее допускать.

Главная проблема состояла в том, что копирование объекта при передаче по значению идет неправильно. Побайтовая копия в данном случае нам не подойдет. В данной ситуации недостаточно было скопировать указатель в поле класса. Нужно было и создать копия данных в куче, а указатель в копии объекта должен быть хранить адрес этой скопированной области памяти.

Значит, достаточно часто, как минимум когда в классе имеются динамические данные, копировать объект нужно по особым правилам, которые можем предусмотреть только мы, создатели класса, потому что только мы на основе хранимых данных можем предусмотреть, как правильно нужно скопировать данные.

Для таких целей был придуман конструктор копирования. Конструктор копирования вызывается всегда, когда объект должен быть скопирован. Такой конструктор имеет строгий фиксированный синтаксис - как параметр он принимает ссылку на объект того же типа данных, что и сам класс.

   class Student
   {
      char* fio = nullptr;
   public:
      Student(const char* fio)
      {
         setFio(fio);
      }
      Student(Student& anotherStudent) // Это и есть конструктор копирования
      {
         cout << "Copy constructor called" << endl;
         setFio(anotherStudent.getFio()); // Здесь мы выделим новую память под fio и скопируем туда данные
      }
      ~Student()
      {
         delete[] fio;
      }
      void setFio(const char* fio)
      {
         if (this->fio != nullptr)
            delete[] this->fio;
         this->fio = new char[strlen(fio) + 1];
         strcpy(this->fio, fio);
      }
      const char* getFio()
      {
         return fio;
      }
   };

   void func(Student student)
   {
      student.setFio("another fio");
   }

Итак, теперь при вызове

     Student good("Ivanov Ivan Ivanovich");
     func(good);

память будет выглядеть совсем по-другому Alt  Texr

В куче у нас теперь две строки "Ivanov Ivan Ivanovich" и оригинал с копией каждый хранят указатель на свою собственную строку в куче.

Копия изменит свою строку на "another fio", а оригинал останется нетронутым. Alt  Texr

И все теперь будет хорошо.

Итак. Конструктор копирования нужен для корректного копирования сложных объектов, которые не будут корректно скопированы побайтово. В частности, это ВСЕГДА относится к объектам, которые содержат любые динамические данные.

Если в объекте есть поле типа указатель, то такому объекту обязательно нужен конструктор копирования, чтобы избежать таких коллизий.

Конструктор копирования вызывается в 3 случаях:

  1. передача объекта по значению
  2. возврат объекта по значению
  3. инициализация объекта путем присваивания другого объекта

Например, вот так:

    Student st2 = good; 

При такой операции тоже будет вызван конструктор копирования.

А вот тут:

     Student st2("Petrov");
     st2 = good;

Тут конструктор копирования вызван не будет. Потому что это не инициализация объекта, а уже обычный вызов оператора =. Если вы хотите, чтобы такая операция срабатывала, то нужно перегружать оператор = (который между прочим не обязательно должен принимать параметром объект того же типа данных, вы можете сделать присваивание и алгоритм работы с любым объектом ^_^ ).

    void main()
    {
       StudentDummy st("Ivanov Ivan Ivanovich");
       func(st);
       cout << "StudentDummy fio = " << st.getFio() << endl;

       Student good("Ivanov Ivan Ivanovich");
       func(good);
       cout << "Student fio = " << good.getFio() << endl;

       Student st2 = good;
     }

В конце программы будет ошибка. Вызвана она будет деструктором класса StudentDummy. Как вы помните внутри func уже было высвобождение памяти по указателю fio из объекта st. А при завершении программы вызовется деструктор объекта, в котором будет попытка снова высвободить эту же память. Естественно будет ошибка, потому что память-то уже освобождена.

В общем, не пугайтесь ошибки. Так и задумано.

⚠️ **GitHub.com Fallback** ⚠️