3.2. Преобразование типов

§ 33. Типизация. В настоящей главе мы рассмотрим важную в контексте объектно-ориентированного программирование тему преобразования типов. Но начнем с краткой характеристики понятия типизации, или системы типов (type system) языка программирования.

Терминология, относящаяся к типизации не всегда однозначна. «Почти каждый язык программирования располагает системой типов какого-то вида. С течением времени системы типов классифицировались как строгие (strong) и слабые (weak), безопасные и небезопасные, статические и динамические; встречалась также масса более экзотических вариантов. Важность понимания системы типов, с которой приходится работать, должна быть очевидной. К тому же вполне разумно ожидать, что знание категорий, к которым относится язык, предоставит много информации в этом направлении. Но поскольку разные люди применяют отличающуюся терминологию, недоразумения практически неизбежны.» [Скит 20]

Мы не ставим целью анализ всех аспектов системы типов, так как это выходит за рамки книги. Ограничимся некоторыми вопросами, понимание которых необходимо для рассмотрения преобразования типов в ООП. Синтаксис C# диктует нам следующий способ работы с переменными: (1) мы объявляем переменную однозначно указывая ее тип, (2) мы можем использовать переменную только для хранения значений указанного типа и не можем изменить этот тип после объявления и (3) компилятор на этапе компиляции проверяет, поддерживает ли эта переменная методы, которые мы для нее вызываем. Эти пункты в первом приближении определяют характеристики сильной типизации (strong).


// Ошибка компляции: переменная еще не объявлена.
x = 5;
int x = 5;
// Ошибка комплияции: не указан тип переменной39.
var y;
// Ошибка компиляции: встроенный тип int не имеет метода Add (int v).
x.Add (5);
// Ошибка комплияции: x уже объявлена как int и нельзя изменять ее тип.
float x;

Однако есть языки программирования, в которых не выполняется некоторые или ни одно из перечисленных условий. Тогда говорят о слабой типизации (weak). Изучите следующий пример на языке JavaScript:


// Объявляется переменная без явного указания типа.
var x;
x = 5;
// Неявно меняется тип переменной.
x = ‘abc’; 
// Проверка поддержки типом переменной x метода Add 
// проводится на этапе выполнения программы.
x.Add (); 

Отметим, что термины «сильная» и «слабая» типизация используются здесь с оговоркой, что их интерпретация в разных источниках может существенно расходиться. Однако в общем случае речь идет о том, насколько много ограничений и проверок реализуется системой типов языка. Приведем определение по [Буч 3]:

Типизация – это способ защититься от использования объектов одного класса вместо другого, или по крайней мере управлять таким использованием.

Большинство современных объектно-ориентированных языков сильно типизированы, однако язык вполне может быть объектно-ориентированным и, одновременно, слабо типизированным [Буч 3], например, таковым был распространенный в 80-х и уже устаревший Smalltalk.

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

Мы ограничимся приведённой краткой характеристикой типизации. Следующие параграфы посвящены одному аспекту системы типов – преобразованию переменных одного типа в переменные другого типа.

§ 34. Преобразование встроенных типов. Мы уже отметили, что в C# нельзя изменить тип переменной после объявления. Но зададимся вопросом: можно ли изменить тип значения переменной, то есть присвоить значение переменной одного типа переменной другого типа?

В широком смысле слова тип – это описание 1) структуры данных и 2) операций с этой структурой данных. Соответственно, изменение типа значения подразумевает или 1) изменение структуры данных, или 2) операций, или 3) и структуры, и операций. В строго типизированных языках, к которым относится C#, тип переменной указывается обязательно и при преобразовании всегда создается новая переменная, в которую некоторым образом записывается преобразованное значение исходной.

Положим, есть вещественное число типа double и нам требуется преобразовать его в тип float, например, чтобы передать в метод, принимающий только тип float. Здесь под «преобразовать» имеется в виду, что нужно изменить и структуру хранения (сохранить число в 4 байтах вместо 8 байт), и набор методов. Хотя сам перечень методов в этом примере остается одинаковым (почти), но для float используется другая реализация этих методов, которая умеет работать с 4-байтовой структурой.

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


double a = 123.456;
float b = (float)a;

Такая запись возможна, так как для типа double определен оператор преобразования в тип float:


operator float(double value) { /* ... */ }

Оператор преобразования типа – это специальный метод. В данном случае – метод, определенный в классе double. Аналогично конструкторам для оператора преобразования типа не указывается тип возвращаемого значения, а имя оператора (float) совпадет с именем целевого типа41.

Если соответствующего метода-оператора в классе нет, то будет ошибка компиляции:


Point p = (Point) a; // ошибка компиляции
operator Point (double value); // так как такого оператора нет в типе double

Обратим внимание, что в примере с преобразованием из double во float компилятор знает и тип переменной b и тип переменной a и вполне мог бы сам попытаться вызывать, при наличии, соответствующий оператор. Однако если вы попробуете пропустить оператор, будет ошибка компиляции. В действительности оператор преобразования типа может быть объявлен или как явный (explicit) или как неявный (implicit). В первом случае он должен указываться обязательно, во втором – может быть опущен. Для пары double-float преобразование из double во float реализовано явным оператором, а обратно – неявным:


explicit operatotr float (double value);
implicit operator double (float value);
// ...
// Ошибка комплияции: нет подходящего неявного оператора преобразованя.
float b = a; 
float b = 123.456;
// Корректно, так как подходящий оператор неявного преобразования есть.
double c = b;
// Тоже возможно, неявный оператор все равно можно указать явно.
double d = (double)b;

Почему эти операторы для double и float определены именно таким образом?

Причина в том, что некоторые преобразования чисел могут привести к потере информации (точности), а некоторые – не могут. При преобразовании из float к double мы не потеряем точности, то есть не пропадут никакие значимые цифры числа42. При преобразовании же обратно потеря точности возможна, так как числа, которые поместятся в 8 байт, могут не поместиться в 4 байта, и произойдет автоматическое округление – лишние цифры будут отброшены. Поэтому, если преобразование безопасно, то есть не может привести к потере данных или ошибкам – принято разрешать неявные преобразования, если же преобразование небезопасно, то есть может привести к потерям данных или другим ошибкам – то принято использовать явные операторы, чтобы программист подтвердил, что да, он тут действительно осознанно выполняет преобразование, а не случайно допустил ошибку.

Отметим, что вся описанная логика вполне могла бы быть реализована обычными методами или конструкторами, а не операторами, например, float ConvertToFloat (double value) и double ConvertToDouble (double float). Действительно, операторы преобразования используются лишь для удобства. В C# существует несколько встроенных способов преобразования типов помимо оператора преобразования: оператор as, методы целевого типа Parse и TypeParse, методы специального класса Convert.

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


int x = Convert.ToInt32 (“12345”); 

Почему для этого преобразования разработчики языка решили использовать синтаксис метода, а не оператора? Логика тут в том, что преобразования между числами, проанализированные нами ранее, являются преобразованиями с сохранением семантики данных: числа остаются числами, но мы, возможно, теряем точность и меняем представление. Мы продолжаем работать с числами. Здесь же выполняется более существенное изменение.

§ 35. Преобразование пользовательских типов. До сих пор мы обсуждали встроенные типы данных. Для пользовательских типов данных программист может сам определить необходимые методы и операторы преобразования.

Мы уже рассматривали тему реализации перегрузки различных операторов в § 23. Покажем определение оператора преобразования43 типа от Line к Polyline:


class Line
{
   // Класс Line хранит две точки.
  public Point A;
  public Point B;
} 
class Polyline
{
   // Класс хранит массив точек – вершин ломаной линии.
  private Point[] vertices;
  public Polyline (params44 Point[] vertices)
  {
    this.vertices = new Point[vertices.Length];
    for (int i = 0; i < vertices.Length; i++)
    {
      this.vertices[i] = vertices[i].Copy();
    }
  }
  
   // Явный оператор преобразования типа из Line в Polyline.
  public static explicit operator Polyline (Line line)
  {
    Polyline p = new Polyline (line.A, line.B);
    return p;
  }
}
Line line = new Line();
//...
// Использование оператора преобразования типа.
Polygon polygon = (Polygon)line;

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

При этом мы вполне можем обойтись обычным методом с произвольным именем вместо оператора:


class Line
{
  public Polyline ToPolyline ()
  {
    return new Polyline(A, B);
  }
}

Или использовать конструктор класса Polyline:


class Polyline
{
  public Polyline (Line line) : this (line.A, line.B)
  {    
  }
}

Эти примеры демонстрируют, что преобразование типов – это конструирование целевого объекта на основе всех или части данных исходного, а оператор –синтаксическое сокращение для вызова конструктора.

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

Классы Line и Polyline из предыдущего параграфа независимы. Рассмотрим другую ситуацию – когда выполняется преобразование между базовым и производным классом. Вспомним пример из предыдущего раздела:


class Shape { /* ... */ }
class Triangle : Shape { /* ... */ }
class Square : Shape { /* ... */ }
Triangle t = new Triangle();
Shape s = (Shape)t;

Потребуется ли реализовывать перегрузку оператора преобразования для этой ситуации? Прежде чем ответить на этот вопрос, зададимся еще одним: а когда вообще может потребоваться такое преобразование от производного класса (Triangle) к базовому (Shape)?

Пусть в классе Shape реализован метод перемещения фигуры:


class Shape { public Move(float shiftX, float shiftY) { /* … */ } }

Положим также, в классе Canvas (холст) мы храним геометрические фигуры, а сам класс реализует методы их массовой обработки. Как будет выглядеть реализация массового смещения разных фигур?


class Canvas
{
  Triangle[] triangles;
  Square[] squares;
  public void Move (float shiftX, float shiftY)
  {
    for (int i = 0; i < triangles.Length; i++)
   {
     triangles[i].Move (shiftX, shiftY);
   }
   for (int i = 0; i < squares.Length; i++)
   {  
     squares[i].Move (shiftX, shiftY);
   }
  }
}

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

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


class Canvas
{
  private Shape[] shapes;
  public Canvas (Triangle[] triangles, Square[] squares)
  {
    shapes = new Shape[triangles.Length + squares.Length];
    // ? Копирование указателей (без преобразования объектов) 
    // ? triangles и squres в shapes.
  }
  public void Move (float shiftX, float shiftY)
  {
    for (int i = 0; i < triangles.Length; i++)
   {
     shapes [i].Move (shiftX, shiftY);
   }
  }
}

Покажем эту (желаемую) возможность на следующей схеме:

Исходно у нас есть два массива, которые мы передаем в конструктор класса Canvas: массив треугольников triangles и массив квадратов squares. Сами объекты (экземпляры классов Triangle и Squares) показаны заштрихованными ячейками. Мы бы хотели сохранить указатели на эти объекты в массив Shape[] и иметь возможность вызывать методы класса Shape применительно к этим объектам, не зная в момент написания кода (компиляции), для объекта какого именно из производных классов будет вызван метод базового класса. То есть преобразовывать не тип самого объекта, путем создания нового объекта и копирования туда части или всего содержимого исходного, как мы делали в предыдущем параграфе, а преобразовывать тип указателя на объект. При этом «через» указатель типа Shape мы «видим» только часть полей и методов объекта, определённых в классе Shape, а «через» указатель типа Triangle, мы видим все поля и методы этого класса – и собственные, и наследуемые. Такая возможность действительно серьезно упрощает решение многих задач, позволяя оперировать указателями базового класса, не отвлекаясь на конкретные типы, там, где это не нужно.

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


Triangle t = new Triangle();
Shape s = (Shape)t;
// Используем метод базового класса (Shape), 
// не зная на этапе компиляции реального типа объекта в памяти.
s.Move (5, 5);
// Оператор преобразования от подтипа к базовому типу неявный.
Shape s2 = t;

Эта логика является частью языка и не требует определения операторов преобразования45.

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


Triangle t2 = (Triangle) s;
Circle c = (Circle) s; // Ошибка во время выполнения.

Преобразование от производного типа к базовому безопасно, в том смысле, что оно всегда возможно, так как объект производного типа всегда содержит в себе «часть» базового типа. Поэтому это преобразование определено как неявное. Однако обратное преобразование, от базового типа к производному, небезопасно, в том смысле, что оно не всегда возможно. Поэтому оно определено как явное.

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


if (s is Circle)
{
  Circle c = (Cirlce) s; // оператор все равно явный.
}

При этом оператор is проверяет не реальный тип объекта, а является ли указанный тип типом объекта или типом одного из его базовых классов в иерархии наследования:


class A { /* … */ }
class B : A { /* … */ }
class C : B { /* … */ }
class D : B { /* … */ }
class E : B { /* … */ }
A obj = new C();
Console.WriteLine (obj is E); // false
Console.WriteLine (obj is D); // false
Console.WriteLine (obj is C); // true
Console.WriteLine (obj is B); // true
Console.WriteLine (obj is A); // true

Реальный46 тип объекта можно получить методом GetType():


Console.WriteLine (obj.GetType() == typeof(E)); // false
Console.WriteLine (obj.GetType() == typeof(D)); // false
Console.WriteLine (obj.GetType() == typeof(C)); // true
Console.WriteLine (obj.GetType() == typeof(B)); // false
Console.WriteLine (obj.GetType() == typeof(A)); // false

В отладке мы также видим реальный тип объекта:

Рассмотренные возможности преобразования типа между базовым и производным классом приведены на следующем рисунке:

В заключение, отметим, что для обозначения преобразования типов используются два термина: преобразование (convert) и приведение (cast). Специфика использования этих терминов зависит от языка программирования. Например, в официальной документации C# явный оператор преобразования типа последовательно называется оператором приведения типа. Однако, на практике, даже в рамках одного языка, термины «преобразование» и «приведение» используются как синонимы, если явно не оговаривается иное. Поэтому в этой книге для избегания путаницы мы везде используем термин «преобразование».

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

  1. Дайте определения следующим терминам, а также сопоставьте русские и английские термины: типизация, сильная типизация, слабая типизация, преобразование типов, приведение типов, convert, cast, strong type system, weak type system.

  2. Почему преобразование типа от производного класса к базовому называют безопасным, а обратное – небезопасным?

  3. Почему в именах методов преобразования типа указывается целевой тип, но не указывается исходный (например, Convert.ToInt)?

  4. * Положим есть класс узла дерева NodeA и класс дерева TreeNodeA. Возможно ли создать производный класс дерева, каждый узел которого будет производным от NodeA?

  5. Класс B – производный от класса A, класс C – производный от класса B. Какого типа переменная base в классе С?

  6. Рассмотрите следующий код.


class F { }
class G : F { }
class H : G { }
public void Compare ()
{
  H h = new H();
  G g = h;
  F f = g;
  G g2 = (G)f;
  H h2 = (H)g2;
} 

Будут ли равны (с точки зрения оператора сравнения ==) переменные h, g, f, g2, h2?

  1. ** В некоторых языках программирования существуют указатели на методы. Покажите, каким образом можно решить рассмотренную в § 34 задачу со смещением фигур разных типов, используя вместо массива Shape[] массив указателей на метод Move для объектов разных типов. Почему это решение будет хуже рассмотренного в тексте?

  2. ** Познакомьтесь с понятиями ковариантности и контравариантности.


Примечания:

  1. 39.  Возможно, читатель знаком с ключевым словом var в C#. Однако оно используется исключительно для упрощения синтаксиса, когда из правой части выражения однозначно понятно на этапе компиляции, какой тип имеет переменная, например: var x = 5 (создается переменная типа int), или: var arr = new int[3] (создается переменная типа int[]).

    ↩︎
  2. 40.  Такой оператор также называют оператором приведения (cast) типа. Мы вернемся к вопросу используемых терминов в завершении настоящей главы.

    ↩︎
  3. 41.  Повторим комментарий, который мы приводили при обсуждении конструкторов: «Обычно (в документации) говорят, что конструктор – метод без указания возвращаемого типа с именем, совпадающим с именем класса. Однако можно взглянуть на это и по-другому, считая, что это метод без имени с возвращаемым типом, совпадающим с именем класса.» Аналогично для операторов преобразования можно интерпретировать имя целевого типа не как имя оператора, а как возвращаемый тип.

    ↩︎
  4. 42.  Строго говоря, полученное число типа double может оказаться не равным исходному числу float, так как в десятичном представлении могут появиться ненулевые цифры в добавленных разрядах. Читатель может провести эксперимент, запустив следующий код: float x = 0.012f; double y = x; Console.WriteLine (y). Однако, это уже относится к теме представления числовых данных и не имеет прямого отношения к рассматриваемым вопросам типизации.

    ↩︎
  5. 43.  Заметим, что часто говорят не об определении, а о перегрузке (overloading) оператора преобразования, даже если он ранее не был определен.

    ↩︎
  6. 44.  В C# ключевое слово params обозначает, что в метод может быть передано произвольное число параметров указанного типа (Point), которые будут упакованы в массив (Point[]) и в таком виде доступны в методе. Так, в настоящем примере, при вызове конструктора передается две точки: new Polyline (line.A, line.B), эти точки упаковываются в массив vertices, где vertices[0] – точка A, vertices[1] – точка B.

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

    ↩︎
  8. 46.  Отметим, что нет устоявшегося термина, обозначающего «реальный», то есть заявленный при создании (вызове оператора new), тип объекта.

    ↩︎

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

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

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