PVOID - Классы (II)

Типы в C++ взаимодействуют с кодом не только посредством конструкций и присваиваний, но и с помощью операторов...

Этот раздел является переводом туториала C++ Language


 Перегрузка операторов


Класс по сути определяют новый тип данных для использования в коде C++. Типы в C++ взаимодействуют с кодом не только посредством конструкций и присваиваний, но и с помощью операторов. Например, рассмотрим операцию над основным типом данных:

int a, b, c;
a = b + c;

здесь к различным переменным фундаментального типа (int) применяется оператор сложения, а затем оператор присваивания. Для основных арифметических типов использование этих операторов обычно очевидно и однозначно, но это может быть не так для некоторых классов. Например:

struct myclass {
  string product;
  float price;
} a, b, c;
a = b + c;

   В этом случае не очевидно, каков будет результат применения оператора сложения к b и c. Фактически, сам этот код может быть причиной ошибки компиляции, пока в типе myclass не будет описано поведение для сложения. Однако, C++ позволяет произвести перегрузку большинства операторов так, что их поведение может быть описано для любого типа, включая классы. Далее приведен список операторов, которые могут быть перегружены:

Перегружаемые операторы
+    -    *    /    =    <    >    +=   -=   *=   /=   <<   >>
<<=  >>=  ==   !=   <=   >=   ++   --   %    &    ^    !    |
~    &=   ^=   |=   &&   ||   %=   []   ()   ,    ->*  ->   new
delete    new[]     delete[]

  Операторы могут быть перегружены посредством функции operator и представляют собой обычные функции со специальными именами: их имена начинаются с ключевого слова operator, за которым следует знак перегружаемого оператора. Синтаксис:

type operator sign (parameters) { /*... body ...*/ }

   Например, вектор в декартовой системе координат состоит из двух координат: x и y. Операция сложения для двух векторов определяется как сложение координат x и y соответственно. Это может быть описано на C++ следующим образом:

// пример перегрузки операторов
#include <iostream>
using namespace std;

class CVector {
  public:
    int x,y;
    CVector () {};
    CVector (int a,int b) : x(a), y(b) {}
    CVector operator + (const CVector&);
};

CVector CVector::operator+ (const CVector& param) {
  CVector temp;
  temp.x = x + param.x;
  temp.y = y + param.y;
  return temp;
}

int main () {
  CVector foo (3,1);
  CVector bar (1,2);
  CVector result;
  result = foo + bar;
  cout << result.x << ',' << result.y << '\n';
  return 0;
}
4,3

Многократное использование CVector в этом коде обусловлено тем, что часть из них ссылается на имя класса (т.е. на тип) CVector, а некоторые другие это функции с таким именем, т.е. конструкторы, которые должны иметь то же имя, что и класс. Например:

CVector (int, int) : x(a), y(b) {}  // функция с именем CVector (конструктор)
CVector operator+ (const CVector&); // функция, возвращающая CVector  

   Функция operator+ класса CVector перегружает оператор сложения (+) для этого типа. Определенная однажды, эта функция может быть вызвана неявно с использованием оператора, или явно через имя функции:

c = a + b;
c = a.operator+ (b);

   Эти выражения эквивалентны.

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

Параметр, ожидаемый перегруженным методом, для таких операций как operator+ - это обычно операнд с правой стороны оператора. Это общее правило для всех бинарных операторов (в которых один операнд слева и один операнд справа). Но операторы могут иметь различные формы. Далее приведена таблица со списком параметров, необходимых каждому оператору, который может быть перегружен (@ следует заменить на соответствующий оператор):

Выражение Оператор Метод Функция
@a + - * & ! ~ ++ -- A::operator@() operator@(A)
a@ ++ -- A::operator@(int) operator@(A,int)
a@b + - * / % ^ & | < > == != <= >= << >> && || , A::operator@(B) operator@(A,B)
a@b = += -= *= /= %= ^= &= |= <<= >>= [] A::operator@(B)  
a(b,c...) () A::operator()(B,C...)  
a->b -> A::operator->()  
(TYPE) a TYPE A::operator TYPE()  

Где a - это объект класса A, b - это объект класса b, а c - это объект класса C. TYPE - это любой тип (операторы перегружают приведение к типу TYPE).

Заметим, что некоторые параметры могут быть перегружены в двух формах: как метод класса или как функция: первый случай описан в примере для operator+. Но некоторые операторы могут также быть перегружены как функции. В этом случае функция operator принимает объект соответствующего класса как первый аргумент:

//перегрузка оператора как функции
#include <iostream>
using namespace std;

class CVector {
  public:
    int x,y;
    CVector () {}
    CVector (int a, int b) : x(a), y(b) {}
};


CVector operator+ (const CVector& lhs, const CVector& rhs) {
  CVector temp;
  temp.x = lhs.x + rhs.x;
  temp.y = lhs.y + rhs.y;
  return temp;
}

int main () {
  CVector foo (3,1);
  CVector bar (1,2);
  CVector result;
  result = foo + bar;
  cout << result.x << ',' << result.y << '\n';
  return 0;
}
4,3

 Ключевое слово this


   Ключевое слово this представляет собой указатель на объект, метод которого выполняется. Он используется в методах классов для обращения объекта к самому себе.

При помощи this можно проверить, не является ли параметр, переданный в метод, этим самым объектом. Например:

// пример использования this
#include <iostream>
using namespace std;

class Dummy {
  public:
    bool isitme (Dummy& param);
};

bool Dummy::isitme (Dummy& param)
{
  if (&param == this) return true;
  else return false;
}

int main () {
  Dummy a;
  Dummy* b = &a;
  if ( b->isitme(a) )
    cout << "yes, &a is b\n";
  return 0;
}
yes, &a is b

   Также this часто используется в методе operator=, который возвращает объект по ссылке. Для примера с двумерным вектором, описанного выше, этот метод может быть определен так:

CVector& CVector::operator= (const CVector& param)
{
  x=param.x;
  y=param.y;
  return *this;
}

 Фактически, этот метод очень похож на код, генерируемый компилятором неявно для этого класса для operator=.

Статические члены


Класс может содержать статические члены: как поля, так и методы.

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

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

// статические члены в классах
#include <iostream>
using namespace std;

class Dummy {
  public:
    static int n;
    Dummy () { n++; };
};

int Dummy::n=0;

int main () {
  Dummy a;
  Dummy b[5];
  cout << a.n << '\n';
  Dummy * c = new Dummy;
  cout << Dummy::n << '\n';
  delete c;
  return 0;
}
6
7

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

int Dummy::n=0;

   Поскольку значение поля является общим для всех объектов класса, обращаться к нему можно как к полю объекта некоторого класса или прямо через имя класса (это, конечно, возможно только для статических полей):

cout << a.n;
cout << Dummy::n;

   Два эти вызова выше обращаются к одной переменной: к статической переменной n класса Dummy, разделенной между всеми объекта этого класса.

Кроме того, n похожа на обычную переменную, но с именем требующим обращения через имя класса (или через объект).

Классы также могут иметь статические методы. Они представляют собой то же: члены класса, общие для всех объектов этого класса, действующие так же, как обычные функции, но доступные как члены класса. По той причине, что статические методы ведут себя как обычные функции, они не имею доступа к нестатическим членам класса (ни к полям, ни к методам). Также они не могут использовать ключевое слово this.

Константные методы


 Когда объект класса объявлен как const:

const MyClass myobject;

   Доступ к полям извне класса ограничен чтением (read-only), как будто все поля класса имеют модификатор const для доступа к ним извне. Однако, конструктор все равно вызывается и позволяет инициализировать и модифицировать эти поля:

// конструктор константного объекта 
#include <iostream>
using namespace std;

class MyClass {
  public:
    int x;
    MyClass(int val) : x(val) {}
    int get() {return x;}
};

int main() {
  const MyClass foo(10);
// foo.x = 20;            // неверно: поле x не может быть изменено
  cout << foo.x << '\n';  // ok: поле x может быть прочтено
  return 0;
}
10

   Поля такого объекта могут быть вызваны, только если они объявлены как const. В примере выше, метод get() (который не обозначен как const) не может быть вызван через foo. Чтобы обозначить его как const, ключевое слово const должно следовать за прототипом метода после закрывающей скобки для параметров.

int get() const {return x;}

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

int get() const {return x;}        // константный метод
const int& get() {return x;}       // метод, возвращающий const&
const int& get() const {return x;} // константный метод, возвращающий a const&

   Методы, определенные как const, не могут могут модифицировать  нестатические поля и вызывать другие неконстантные методы. В сущности, константные члены не должны модифицировать состояние объекта.

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

Можно подумать, что в любом случае Вы редко объявлять константные объекты и поэтому маркировка всех членов, которые не модифицируют объект, как const не стоит потраченных усилий, но константные объекты на самом деле очень распространены. Большинство функций принимают объекты классов в качестве параметров, взяв их по константной ссылке и, следовательно, эти функции могут получить доступ только к константным членам:

// константные объекты
#include <iostream>
using namespace std;

class MyClass {
    int x;
  public:
    MyClass(int val) : x(val) {}
    const int& get() const {return x;}
};

void print (const MyClass& arg) {
  cout << arg.get() << '\n';
}

int main() {
  MyClass foo (10);
  print(foo);

  return 0;
}
10

 Если в этом примере не обозначить get() как const, вызов arg.get() в функции print() будет невозможен, потому что константные объекты имеют доступ только к константным методам.

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

// перегрузка методов по константности
#include <iostream>
using namespace std;

class MyClass {
    int x;
  public:
    MyClass(int val) : x(val) {}
    const int& get() const {return x;}
    int& get() {return x;}
};

int main() {
  MyClass foo (10);
  const MyClass bar (20);
  foo.get() = 15;         // ok: get() returns int&
// bar.get() = 25;        // not valid: get() returns const int&
  cout << foo.get() << '\n';
  cout << bar.get() << '\n';

  return 0;
}
15
20

 Шаблоны классов


   Так же, как мы можем создавать шаблоны функций, мы можем создавать и шаблоны классов, позволяющие классам иметь члены, использующие параметры шаблона как типы. Например:

template <class T>
class mypair {
    T values [2];
  public:
    mypair (T first, T second)
    {
      values[0]=first; values[1]=second;
    }
};

   Класс, который мы только что определили, служит для хранения двух элементов любого допустимого типа. Например, если мы хотим объявить объект этого класса для хранения двух целых значений типа int со значениями 115 и 36, мы должны написать:

mypair<int> myobject (115, 36);

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

mypair<double> myfloats (3.0, 2.18);

   Конструктор является единственным методом в предыдущем шаблоне класса и он определен внутри класса. В случае, когда метод определен вне определения шаблона класса, ему должен предшествовать префикс template<...>

// шаблон класса
#include <iostream>
using namespace std;

template <class T>
class mypair {
    T a, b;
  public:
    mypair (T first, T second)
      {a=first; b=second;}
    T getmax ();
};

template <class T>
T mypair<T>::getmax ()
{
  T retval;
  retval = a>b? a : b;
  return retval;
}

int main () {
  mypair <int> myobject (100, 75);
  cout << myobject.getmax();
  return 0;
}ats (3.0, 2.18);
100

 Отметим синтаксис определения метода getmax():

template <class T>
T mypair<T>::getmax ()

   Смущены частым использованием T? В этом объявлении три T: первое - это паравметр шаблона. Второе обозначает тип, возвращаемый методом. И третье T (внутри угловых скобок) указывает, что этот параметр шаблона метода также также является параметром шаблона класса.

Специализация шаблонов


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

   Предположим, что мы имеем очень просто класс mycontainer, который может хранить одно значение любого типа, а также метод increase(), который увеличивает это значение. Мы видим, что когда класс хранит элемент типа char, удобнее иметь полностью различающиеся реализации: в случае с char, метод удобнее назвать uppercase(), таким образом мы создадим специализацию шаблона класса для нужного типа данных:

// специализация шаблона
#include <iostream>
using namespace std;

// наблон класса:
template <class T>
class mycontainer {
    T element;
  public:
    mycontainer (T arg) {element=arg;}
    T increase () {return ++element;}
};

// специализация шаблона класса:
template <>
class mycontainer <char> {
    char element;
  public:
    mycontainer (char arg) {element=arg;}
    char uppercase ()
    {
      if ((element>='a')&&(element<='z'))
      element+='A'-'a';
      return element;
    }
};

int main () {
  mycontainer<int> myint (7);
  mycontainer<char> mychar ('j');
  cout << myint.increase() << endl;
  cout << mychar.uppercase() << endl;
  return 0;
}
8
J

 Такой синтаксис используется для специализации шаблона класса:

template <> class mycontainer <char> { ... };

   Стоит обратить внимание на использование template<> перед именем класса с пустым списком параметров. Типы данных заранее известны, поэтому аргументов не требуется.

Но куда более важна специализация <char> - параметр после имени шаблона класса. Этот параметр описывает тип, для которого производится специализация. Разница между основным шаблоном класса и специализацией:

template <class T> class mycontainer { ... };
template <> class mycontainer <char> { ... };

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