2.4. Присваивание, копирование и сравнение объектов

§ 19. Присваивание и идентичность. До сих пор мы обсуждали работу с отдельными объектами с момента их создания до уничтожения. В настоящей главе мы рассмотрим вопросы идентичности объектов: что происходит, когда одна ссылочная переменная присваивается другой? как создать копию существующего объекта? и какие объекты считаются равными друг другу?

Рассмотрим следующий пример:


Point p1 = new Point();
Point p2 = p1;

На какой объект будет указывать переменная p2, на тот же, что и p1 (слева на рисунке) или на его копию (справа на рисунке)?

В C# оператор присваивания «=» всегда выполняет копирование по значению (by value), то есть копирует значение переменной p1 в переменную p2. Но их значение – это адрес объекта в памяти. В первой строке примера оператор new возвращает адрес нового объекта Point, который присваивается переменной p1, во второй строке этот адрес копируется в переменную p2.

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


int a = 5;
int b = a;

Ранее мы говорили, что термины «объект» и «переменная» обычно используются как синонимы, но два рассмотренных примера показывают ситуацию, когда следует проводить различие. В первом случае, для ссылочных переменных, значение переменной и объект – не одно и то же, во втором случае, для значимых переменных, – одно и то же.

С вопросом присваивания связан вопрос передачи параметров в метод: при передаче параметров неявно выполняется присваивание переменной-параметру значения передаваемой переменной. Передача параметров выполняется по значению, как и присваивание.

Изучите следующий код, демонстрирующий эти особенности:


// Узел дерева.
class Node 
{
  // Полезная нагрузка узла дерева – целое число.
  public int Value;
  // Дети узла.
  public Node[] Children;
}
// Односвязное дерево целых чисел.
class Tree
{
  // Корень дерева.
  private Node root;
  // Добавить ко всем узлам дерева, 
  // расположенным на уровне minLevel и ниже, значение add.
  public void Add(int add, int minLevel)
  {
    Add(root, add, minLevel);
  }
  // Рекурсивный метод. skip – количество уровней, включая уровень узла node,
  // который нужно пропустить, считая от узла node,
  // прежде чем начать прибавлять значение add.
  private void Add (Node node, int add, int skip)
  {
    if (skip == 0)
    {
      node.Value += add;
    }
    else
    {
      skip--;
    }   
    // Вызываем этот же метод для все детей узла node.
    for (int i = 0; i < node.Children.Length; i++)
    {
      Add(node.Children[i], add, skip);
    }
  }
}

Обратим внимание, что при изменении объекта node в рекурсивном методе Add изменяется не копия, созданная в методе, а исходный объект, существующий в одном экземпляре. С другой стороны, когда мы в методе Add изменяем переменную skip, она не меняется в вызвавшем методе, так как это разные переменные. Схема работы кода представлена на следующем рисунке:

Мы рассмотрели копирование и передачу параметров в метод по значению. Этот способ является способом по умолчанию, так как он в большинстве случаев оптимален: значимые типы, как правило, небольшие и их эффективнее передать по значению в стеке; ссылочные типы могут занимать существенный объем, и копировать их каждый раз в стек было бы слишком расточительно. В разных языках программирования способы копирования и передачи параметров в методы могут различаться, но в общем случае выделяют три способа: по значению (by value), по ссылке (by reference) и по указателю (by pointer).

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


// C++
// by value
int a = 1;
int b = a;
b++;
// здесь: a == 1 && b == 2 (a и b обозначают разные ячейки памяти).


// C++
// by reference
int c = 1;
int &d = c;
d++;
// здесь: c == 2  && d == 2 (c и d обозначают одну и ту же ячейку памяти).


// C++
// by pointer
int e = 1;
int* f = &e;
// здесь f – это переменная типа «указатель на ячейку с целым числом» int*
// оператор * (оператор разыменования) получает значение по адресу f
(*f)++
// здесь (*f) == 2 && e == 2 
// (f – адрес ячейки, которую обозначает переменная e).

В C#, как и других языках со сборкой мусора, нет возможности работы с указателями, поэтому третий способ присваивания и передачи параметров в метод недоступен.

Присваивание по ссылке в C# также невозможно28, однако возможна передача параметра в метод по ссылке. Рассмотрим следующий пример:


int x = 1;
Add (ref x, 1);
Console.WriteLine (x); // 2
// ...
public Add (ref int x, int add)
{
  x += add;
}

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

В C# помимо ключевого слова ref для передачи параметров по ссылке используется ключевое слово out. В обоих случаях выполняется передача по ссылке. Отличие в том, что при использовании ref переменная должна быть инициализирована до передачи в метод, а в методе может быть не задана или не изменена. В случае же out, наоборот, переменная может быть не инициализирована до передачи, но в методе она должна быть инициализирована. Например, в следующем коде мы объявляем переменные x и y, но не инициализируем их до вызова метода GetCoords, они инициализируются в самом методе.


class Point
{
  private float x;
  private float y;
  public void GetCoords (out x, out y)
  {
   x = this.x;
    y = this.y;
  }
}
// ...
Point p = new Point (2,3);
int x;
int y;
p.GetCoords (out x, out y);  
// здесь: x == 2, y == 3.

Приведенные примеры демонстрируют передачу по ссылке параметра значимого типа. При передаче по ссылке параметра ссылочного типа мы можем изменить значение самого указателя в вызывающем методе из вызываемого (при передаче параметра ссылочного типа по значению – не можем). Покажем это на следующем примере:


void SomeMethod (Point p1, ref Point p2)
{
  p1.X = 2;
  p2.X = 2;
  p1 = new Point (3, 3);
  p2 = new Point (3, 3);
}
void Main()
{
  Point p1 = new Point (1, 1);
  Point p2 = new Point (1, 1);
  Point p1copy = p1;
  Point p2copy = p2;
  SomeMethod (p1, ref p2);
  Console.WriteLine (p1.X); // 2
  Console.WriteLine (p2.X); // 3
  Console.WriteLine (p1 == p1copy); // True
  Console.WriteLine (p2 == p2copy); // False
}

То есть передавая параметр в метод по значению, мы можем быть уверены, что значение переменной-параметра в вызывающем методе не изменится, так как вызываемый метод будет менять (если будет) копию переданного значения. Но если переменная-параметр ссылочного типа, то ее значение – указатель, соответственно, вызываемый метод может изменить поля объекта, на который указывает этот указатель (в примере p1.X изменено внутри метода SomeMethod). Если вы не вполне ясно понимаете этот механизм, нарисуйте схему памяти для рассматриваемого примера.

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

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

§ 20. Копирование объектов. Все рассмотренные в предыдущем параграфе механизмы присваивания для ссылочных переменных не предполагают копирование самого объекта. Мы или копируем указатель, или создаем ссылку.

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

Самый очевидный способ – скопировать состояние, то есть значения всех полей объекта:


class Point 
{ 
  private float x;
  private float y;
  public Point Copy() 
  {
    Point copy = new Point(x, y);
    return copy;
  } 
}
Point p2 = p1.Copy();

Эта операция могла бы быть реализована на уровне языка программирования для всех объектов31. Так, в C# доступен специальный метод MemberwiseClone:


Point p2 = p1.MemberwiseClone();

Однако такого рода копирование (клонирование) нельзя назвать универсальным решением. Например, в следующем примере мы получаем побочный эффект:


class Cirle 
{
  public Point Center; 
  public float Radius; 
}
// ...
Circle c1 = new Circle();
Circle c2 = c1.MemberwiseClone();
c2.Center.X = 5;

Вопрос: приведет ли изменение центра второго круга в последней строке примера к изменению центра первого? Очевидно, что да, так как операция клонирования копирует значения всех полей, а значение поля Center – указатель на объект точки.

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

Решит ли проблему универсальный встроенный метод копирования объекта вместе со всеми объектами, на которые тот ссылается? В случае рассмотренного класса окружности, да. Однако в общем случае это также не универсальное решение. Например, рассмотрим класс таблицы Table и класс строки Row. Таблица «знает» перечень своих строк, а строка «знает», к какой таблице она относится:


public Table
{
  private Row[] rows;
  // …
}
class Row
{
  private Table Table;
  // ...
}

Копирование такой одной строки нашим «универсальным» способом приведет к тому, что будет скопирована вся таблица, или мы даже можем получить зацикливание – в зависимости от реализации. Очевидно, это не то, что мы хотим.

Зададимся вопросом: возможно ли универсальное решение задачи копирования объекта? Проблема в том, что само понятие «копия объекта» – это не понятие уровня языка программирования или памяти. Значение этого понятия определяется семантикой класса, то есть смыслом, назначением, его полей. Для разных классов потребуется копировать или не копировать разные поля и это может определить только программист. Поэтому «универсальные» методы, такие как MemberwiseClone, применяются не так часто, а основным способом копирования является явная реализация программистом логики копирования, как мы сделали в первом примере в начале параграфа32. Обратим внимание, что метод копирования всегда будет создавать новый экземпляр и, так или иначе, копировать в него состояние текущего объекта. Поэтому копирование часто реализуют в виде конструктора класса, принимающего аргументом экземпляр того же класса – исходный объект:


class Point 
{ 
  private float x;
  private float y;
  public Point (Point p) 
  {
    x = p.x;
    y = p.y;
  } 
}
Point p1 = new Point (5, 6);
Point p2 = new Point (p1);

Такой конструктор называют также конструктором копирования. Отметим, что с точки зрения компилятора этот конструктор никак не отличается от других конструкторов и обрабатывается «на общих основаниях».

§ 21. Сравнение. С вопросами присваивания и копирования объектов тесно связан вопрос сравнения. Оператор сравнения «==», как и оператор присваивания, выполняет сравнение по значению. Но так как значение для ссылочного типа – указатель, то и сравниваются указатели. Аналогично присваиванию, если мы хотим реализовать сравнение объектов по данным, мы должны реализовывать собственный метод33.

Рассмотрим пример:


class Point
{
  private float x;
  private float y;
  public Point (float x, float y)
  {
    this.x = x; 
    this.y = y;
  }
  public bool IsEqual (Point p)
  {
    if (p == null) return false;    
    return this == p || (x = p.x && y = p.y);
  }
}
Point p1 = new Point (1, 1);
Point p2 = new Point (1, 1);
Console.WriteLine ( p1 == p2 ); // > False
Console.WriteLine ( p1.IsEquals (p2) ); // > True

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

Приведем еще один пример: программа отслеживает положение автобусов, есть класс Bus, метод Redout (получить последние показания физических датчиков местоположения) и свойства X, Y (текущее положение по результатам последнего считывания показаний). У нас есть два объекта, которые привязаны к одному и тому же автобусу: Bus b1; Bus b2. Но для объекта b1 мы давно не взывали метод Readout, а для объекта b2 – только что. Равны ли эти объекты? С точки зрения значений полей (состояние) – нет. Но с точки зрения смысла задачи – да, так как они обозначают один и тот же физический автобус.

§ 22. Разночтения терминологии. В главе 2.1 мы уже обсуждали, что термин «ссылка» в контексте C# обозначает управляемый указатель, однако также имеет второе распространённое значение (из С++) как переменная, обозначающая ту же область памяти, что и некоторая другая переменная. При этом в самом C# этот термин также используется и во втором значении, когда идет речь о передаче параметров в метод по ссылке (ref, out). Также, хотя в C# используется понятие «равенство по ссылке», оно не используется в других языках, таких как Java, где тоже используется термин «ссылка» в значении управляемого указателя. Все эти разночтения создают некоторую путаницу.

В следующей таблице приведено сравнение используемых терминов.

Терминология C# Модель памяти
1 ссылочная переменная (управляемый) указатель
2 значимая переменная (значимая) переменная
3 ссылка (в контексте передачи аргументов метода) ссылка
4 ссылка (в контексте ссылочной переменной) значение (управляемого) указателя, то есть сам адрес
5 копирование или присваивание по значению присваивание по значению
6 копирование или присваивание по ссылке (в контексте передачи аргументов метода) присваивание по ссылке
7 равенство по ссылке, или идентичность, ссылочных переменных равенство переменных-указателей по значению, то есть равенство адресов, которые они хранят
8 равенство по значению ссылочных переменных равенство (семантическое) данных, на которые указывает указатель
9 равенство по значению значимых переменных равенство по значению (значимых) переменных

В настоящей книге мы используем терминологию справа, так как она более прозрачно отражает модель памяти и понятнее для освоения. Кроме того, мы уже указали на некоторые непоследовательности в терминологии слева. Разработчик в любом случае должен знать возможные значения. Читателю следует убедиться, что он вполне понимает все пункты таблицы.

§ 23. Перегрузка операторов. Зададимся вопросом: возможно ли изменить поведение операторов присваивания или сравнения? Возможно ли в принципе изменить поведение какого-либо оператора?

Несколько упрощая, оператор – обычный метод, для вызова которого в языке программирования предлагается специальный синтаксис. Записать для числовых переменных x + y быстрее и понятнее, чем Add (x, y). Записать x = y быстрее и понятнее, чем x.Assign (y). Так, в формулах Excel вместо операторов И и ИЛИ используются соответствующие методы, поэтому вместо того, чтобы написать формулу «=(A3 ИЛИ A4) И A5» мы вынуждены писать «=И(ИЛИ(A3; A4); A5)», что менее удобно, чем если бы мы использовали операторы. Упрощенно говоря, компилятор заменят все операторы на вызовы соответствующих методов. Например, для присваивания целых чисел int, вызывается метод int operator= (int a), для сложения вещественных чисел – float operator+ (float x, float y)34.

Отметим, что кроме операторов, записываемых небуквенными символами, такими как: =, ==, !=, +, -, оператор индексации [] и других, существуют операторы, записываемые буквами, например, в C#: new, typeof, sizeof, is, as и другие.

Многие языки программирования позволяют переопределять встроенное поведение операторов применительно к пользовательским типам данных, то есть переопределять соответствующие методы. Такое переопределение называется перегрузкой операторов.

Перегрузка оператора (operator overloading) – определение собственной реализации оператора для объектов некоторого класса.

Не все операторы можно перегрузить, это зависит от языка программирования. Так, в C# нельзя перегрузить оператор присваивания, но возможно – оператор сравнения. Также подчеркнем, что, перегружая оператор, мы переопределяем поведение этого оператора только применительно к определенным типа данных.

Например, рассмотрим простейший код перегрузки оператора сравнения объектов класса Point:


public class Point
{
  // …
  public static Point operator== (Point a, Point b)
  {  
    // Метод System.ReferenceEquals выполняет сравнение переменных по ссылке.
    if (ReferenceEquals(a, b)) return true;
    return a.x == b.x && a.y == b.y;
  }
  public static Point operator!= (Point a, Point b)
  {
    return !(a == b);
  }
}
// ...
// Использование:
Point p1 = new Point (1, 2);
Point p2 = new Point (1, 2);
// Условно можно представить, что вызывается метод: operator== (p1, p2);
bool isEqual = p1 == p2; 
Console.WriteLine (isEqual); // True, без перегрузки было бы False.

Отметим несколько моментов. Во-первых, некоторые операторы можно перегружать только парами, в частности, операторы «равно» и «не равно», поэтому мы реализовали два метода: operator== и operator!=. Во-вторых, мы применили метод ReferenceEquals для сравнения объектов по ссылке, то есть использования стандартной реализации оператора сравнения, так как, если бы мы записали if (a == b) return true, то это привело бы к рекурсивному вызову перегруженного оператора operator==. И, в-третьих, обратите внимание на синтаксис C#: метод-оператор должен быть в классе, для объектов которого он определяется, должен быть открытым и помечен ключевым словом static. Значение этого ключевого слова мы рассмотрим отдельно в главе 2.6, здесь лишь отметим, что в методы-операторы не передается переменная this.

На рассмотренном примере покажем серьезные проблемы, которые возникают при использовании перегрузки операторов.

Во-первых, использование перегруженного оператора синтаксически скрыто. Программист, читающий в коде строку bool isEqual = p1 == p2, будет уверен, что выполняется стандартное сравнение по значению указателя, так как, в отличие от перегруженных методов, мы не можем определить, какая реализация оператора используется, глядя только на строку, где она используется.

Вторая проблема состоит в том, что программист может придать привычным операторам совершенно не свойственное им поведение. Например, компилятор не запретит переопределить оператор «==», который будет возвращать true, когда объекты не равны, а оператор «!=», который будет возвращать true, когда объекты равны. Такое несвойственное поведение будет всегда неожиданностью и будет приводить к ошибкам.

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

Ограниченная область применения и описанные проблемы объясняют тот факт, что в разных языках поддержка перегрузки операторов сильно отличается. Так в С++, который следует концепции максимальной гибкости под ответственность программиста, разрешается перегружать практически любые операторы. С другой стороны, в управляемых языках, ориентирующихся на другие задачи, таких как C#, Java, Python, возможности перегрузки существенно ограничены.

В главе не рассматриваются такие возможности C# как кортежи (tuples), записи (records), возврат значений по ссылке и некоторые другие.

Вопросы и задания

  1. Охарактеризуйте различия а) присваивания, б) копирования, в) передачи параметров в метод и г) возврата результат выполнения метода (return) следующими способами: 1) по значению; 2) по ссылке; 3) по указателю.

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

  3. Приведите примеры, когда 1) объекты равны семантически, но имеют различающиеся значения полей; 2) объекты не равны семантически, но имеют идентичные значения полей.

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


public void Main()
{
  Point[] points = new Point[2];
  points[0] = new Point (10, 10);
  points[1] = new Point (20, 20);
  // Что тут хранит массив points, какие значения полей точек?
}
private void M1 (Point[] ps1, ref Point[] ps2)
{
  ps1[0].X = 30;
  ps1 = new Point[1];
  ps2[0].X = 50;
  ps2 = new Point[1];
}
  1. В примере из § 21 объясните зачем нужна строка if (p == null) return false, ведь выражение (Point)null == (Point)null должно возвращать true?

  2. Просмотрите перечень операторов, которые допускается, а которые – не допускается перегружать в C#.

  3. ** Познакомьтесь с механизмом замыканий (closures).


Примечания:

  1. 28.  Строго говоря, в C# версии 7 добавлена возможность возврата значения метода по ссылке (ref returns) и присвоения его локальной переменной по ссылке (ref locals). Однако здесь мы не будем обсуждать эти возможности.

    ↩︎
  2. 29.  Отметим, что ссылки при передаче параметров в методы часто реализуются на более низком уровне (компилятором) как указатели.

    ↩︎
  3. 30.  Отметим, что в некоторых языках программирования, например, в Python, есть возможность возврата из метода нескольких значений (кортежей). В C# такая возможность также добавлена в версии 7, но ее рассмотрение выходит за рамки книги.

    ↩︎
  4. 31.  Копирование значений полей объекта также называется shallow copy – поверхностным копированием.↩︎

  5. 32.  Полное копирование объекта с учетом семантики его полей также называется deep copy – глубоким копированием.

    ↩︎
  6. 33.  Строго говоря, необходимо определять два метода: Equals (сравнение), GetHashCode (вычисление хэш-кода объекта), однако эта тема выходим за рамки книги.

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

    ↩︎

О сайте, об авторе, контакты, оставить отзыв.

Телеграм-канал: newobjx.

© Тимофей Усов, 2019—2020.