3.3. Виртуальные и абстрактные методы

§ 37. Абстрактные методы. Рассмотрим следующий пример. Мы проектируем приложение, отображающее схему производственной установки, на которой размещены датчики температуры и давления. На схеме отображаются текущие показания датчиков. Для каждого типа датчика мы создаем отдельный класс, который «умеет» получать показания по этому типу. При этом мы знаем, что все типы датчиков, с которыми мы планируем работать, измеряют текущее значение только одной величины (температуры или давления), соответственно, все классы будут иметь общее состояние – текущее значение – и общий метод – получение этого текущего значения. Проанализируем следующий код:


// Базовый класс, датчик.
class Sensor
{
  // Текущее значение измеряемой величины.
  protected float curVal;
  
  public float GetCurVal ()
  {
    return curVal;
  }
  // Название датчика.
  protected readonly string name;
  public string GetName()
  {
    return name;
  }
  public Sensor (string name)
  {
    this.name = name;
  }
}
// Датчик температуры.
class TemperatureSensor : Sensor
{
  public TemperaturSensor (string name) : base (name) { }
}
// Датчик давления.
class PressureSensor : Sensor
{
  public PressureSensor (string name) : base (name) { }
}
// Использование: отображение текущих значений для массива Sensor[] sensors:
for (int i = 0; i < sensors.Length; i++)
{
  Console.WriteLine ($”{sensors[i].GetName()} = {sensors[i].GetCurVal()}”);
}

Теперь положим, для чтения текущего значения curVal с устройства используется метод Readout. Где нам его следует разместить: в базовом классе или в производных классах? Если мы его разместим в производных классах, то мы не сможем его вызвать через экземпляр базового класса. Если же мы разместим его в базовом классе – сможем ли мы привести общую реализацию? Очевидно, что нет, так как в этом и причина создания нескольких производных классов – они имеют разную реализацию загрузки данных с устройств.

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


// АНТИШАБЛОН
class Sensor
{
   /* ... */
  public void Readout()
  {
    if (this is TemperatureSensor)
    {
      ((TemperatureSensor)this).ReadoutTemperature48();
    } 
    else if (this is PressureSensor)
    {
      ((PressureSensor)this).ReadoutPressure();
    }
    else   
    {
      throw new NotImplementedException();
    }
  }
}

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


abstract class Sensor
{
  /* ... */
  public abstract void Readout();
}
class TemperatureSensor : Sensor
{
  /* ... */
  public override void Readout()
  {
    // Считывает значение по датчику температуры из базы и сохраняет в curVal.
    Console.WriteLine (‘TemperatureSensor.Readout’);
    curVal = 1;
  }
}
class PressureSensor : Sensor
{
  /* ... */
  public override void Readout()
  {
    // Считывает значение по датчику давления из базы и сохраняет в curVal. 
    Console.WriteLine (‘PressureSensor.Readout’);
    curVal = 2; 
  }
}
Sensors[] sensors = new Sensors[2];
sensors[0] = new TemperatureSensor(“T-101”);
sensors[1] = new PressureSensor (“P-202”);
// Использование для массива Sensor[] sensors:
for (int i = 0; i < sensors.Length; i++)
{
  Sensor s = sensors[i];
  // Сначала актуализируем значение из базы данных.
  s.Readout();
  // Потом выводим его.
  Console.WriteLine ($”{s.GetName()} = {s.GetCurVal()}”);
}

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

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

В приведенном коде в первой итерации цикла (i == 0) переменная s типа Sensor обозначает объект реального типа TemperatureSensor, поэтому при вызове метода s.Readout будет вызван код этого метода из класса TemperatureSensor. В следующей же итерации цикла (i == 1) переменная s типа Sensor обозначает объект другого реального типа – PressureSensor, поэтому при вызове метода s.Readout будет вызван код этого метода из класса PressureSensor.


> TemperatureSensor 
> T-101 = 1
> PressureSensor
> P-202 = 2

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

Сформулируем определения.

Абстрактный метод (abstract) – метод, объявленный без реализации (тела); реализация метода может быть приведена в любом из подклассов; класс, в котором объявлен хотя бы один абстрактный метод или который наследует, но не реализует, хотя бы один абстрактный метод, является абстрактным классом; не допускается создание экземпляров абстрактного класса50.

Переопределение метода (overriding) – определение (с телом или без) в производном классе метода с сигнатурой, совпадающей с сигнатурой метода, определенного в базовом классе.

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

Продемонстрируем это следующим примером:


// Объявлен абстрактный метод void M().
abstract class A { public abstract void M(); }
// Метод базового класса не переопределяется, 
// поэтому класс B также абстрактный.
abstract class B : A { }
// Класс C переопределяет абстрактный метод класса A.
class C : B { public override void M() { Console.WriteLine (“C”); } }
// Класс D переопределеяет метод класса C.
// Для экземлпяров класса D будет использоваться именно эта реализация
// (ближайшая по цепочке наследования), а не из класса C.
class D : C { public override void M() { Console.WriteLine (“D”); } }
// Класс E переопределяет метод класса D как абстрактный,
// таким образом, «скрывает» переопределение из класса D.
abstract class E : D { public abstract override void M(); }
// Класс F переопределяет метод класса E.
class F : E { public override void M() { Console.WriteLine (“F”); } }
// Класс G не переопределяет метод М(), 
// но так как в базовом классе F этот метод переопределен как конкретный,
// то класс G – конкретный класс и при вызове для его экземпляра метода M
// будет вызвана реализация из класса F (ближайшего по цепочке наследования).
class G : F { }
// Использование:
A c = new C();
c.M(); // Вывод: С
A d = new D();
d.M(); // Вывод: D
A f = new F();
f.M(); // Вывод: F
((D)f).M(); // Вывод: F
A g = new G();
g.M() // Вывод: F

То есть, еще раз: при вызове метода класса, в частности, при вызове абстрактного метода, всегда вызывается «самая частная» («самая производная») реализация в иерархии наследования для реального типа объекта, а не для типа переменной, через которую мы вызываем метод. На следующем рисунке показано, что при вызове метода M() для переменной g типа A будет вызвана реализация из класса F, так как она ближе всего в иерархии наследования к реальному типу объекта – G.

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


// Данные отдельного измерения.
class Data
{
  // Время измерения.
  public DateTime Time;
  // Измеренное значение.
  public float Value;
}
abstract class Sensor
{
  /* ... */
  // Теперь это неабстрактный метод базового класса.
  public void Readout()
  {
    // Вызываем из метода базового класса абстрактный метод, 
    // который будет «доопределен» в производном классе.
    Data[] data = ReadoutRaw();
    // Сохраняем последнее значение.
    if (data.Length > 0)
    {
      curValue = data[data.Lenght – 1];
    }
  }
  public abstract Data[] ReadoutRaw();
}
class TemperatureSensor : Sensor
{
  /* ... */
  public override Data[] ReadoutRaw ()
  {
    /* ... */
  }
}
class PressureSensor : Sensor
{
  /* ... */
  public override Data[] ReadoutRaw ()
  {
    /* ... */
  }
}

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


Sensor sensor = new TemperatureSensor();
// Вызов абстрактного метода приводит к выполненению реализации
// для реального типа объекта,
// здесь – к выполнению метода из класса TemperatureSensor
Data[] data = sensor.ReadoutRow();
abstract class Sensor
{
  public void Readout()
  {
    // Эквивалентно: Data[] data = this.ReadoutRaw();
    Data[] data = ReadoutRaw();
    /* ... */
  }
  public abstract Data[] ReadoutRaw();
  /* ... */
}

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


class A
{
  protected abstract string M();
  public A()
  {
    // Использование абстрактных методов в конструкторе – плохая практика!
    N();
  }
  public void N ()
  {
    Console.WriteLine (M());
  }
}
class B
{
  private string m = “1”;
  public B()
  {    
    m = “2”;
  }
  protected override string M()
  {
    return m;
  }
}
B b = new B();
// Вывод: 1
b.N();
// Вывод: 2

Объясните, почему при создании объекта при вызове метода N выводится значение 1, а при явном вызове метода N – 2.

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

§ 38. Виртуальные методы. В примере с датчиками из предыдущего параграфа метод GetCurVal имеет общую для всех производных классов сигнатуру и реализацию (это обычный, «конкретный» метод), а метод Readout имеет общую для всех производных классов сигнатуру, но реализация определяется в каждом из классов отдельно (абстрактный метод). Однако вполне можно представить, что семантика некоторых методов будет таковой, что у них или (первая ситуация) будет общая реализация, но она будет общей только для некоторых производных классов, или (вторая ситуация) для некоторых производных классов потребуется дополнить общую реализацию некоторым кодом. Рассмотрим такие ситуации.

Логика работы метода Readout() вполне устраивает нас для датчиков, считывающих значение с периодичностью раз в несколько секунд. Но, положим, некоторый датчик измеряет скорость потока жидкости в трубе и физически фиксирует значение несколько раз в секунду. В этом случае, последнее из измеренных за интервал значение будет не очень информативно, а большая часть измерений будет пропускаться и не отображаться пользователю вовсе. Мы хотели бы для повышения информативности выводить среднее арифметическое полученных за некоторый период, положим, за прошедшие 3 секунды, значений. Таким образом, реализация метода Readout нас устраивает для производных классов TemperatureSensor и PressureSensor, но для класса VelocityHDSensor нам требуется другая реализация, что соответствует описанной в начале параграфа первой ситуации.

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


abstract class Sensor
{  
  public abstract string GetUnits();
  public string GetCurValString()
  {
    return curVal + “ “ + GetUnit();
  }
}
class PressureSensor : Sensor
{
  public override string GetUnits()
  {
    return “МПа”;
  }
}

Приведенный код вполне решает наши задачи, однако, допустим, для класса VelocityHDSensor, который определяет текущее значение как среднее арифметическое, мы хотели бы дополнить метод GetCurValString, выводя в конце строки в скобках текст “avg”. То есть, если для класса PressureSensor метод вернет, например, “1,2 МПа”, то для класса VelocityHDSensor мы бы хотели получить «0,4 м/с (avg)». Это пример второй ситуации, обозначенной в начале параграфа.

Как мы можем решить эти две задачи?

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


// АНТИШАБЛОН
abstract class Sensor
{   
  public string GetCurValString()
  {
    return GetCurValStringExt (curVal + “ “ + GetUnit());
  }
  public abstract string GetUnits();
  public abstract string GetCurValStringExt (string b);  
}
class TemperatureSensor : Sensor
{
  public string GetCurValStringExt (string b) { return b; }
}
class PressureSensor : Sensor
{
  public string GetCurValStringExt (string b) { return b; }
}
class VelocityHDSensor : Sensor 
{  
  public override string GetCurValStringExt (string b)
  {
    return b + “ (avg)”;
  }
}

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

Внимательный читатель может предложить еще одно решение, подходящее для обоих ситуаций, используя только механизм абстрактных и обычных методов. В предыдущем параграфе мы показывали, что абстрактный метод может быть перегружен несколько раз в иерархии наследования. Соответственно, мы можем определить дополнительный базовый класс SensorBase, в котором определить абстрактные методы Readout и GetCurValString, далее, сначала переопределить их в классе Sensor, а потом, при необходимости, переопределить их в некоторых из производных классов. Проанализируем код:


// Также АНТИШАБЛОН, но существенно лучше предыдущего решения.
abstract class SensorBase
{
  public abstract void Readout();
  public abstract string GetCurValString();
}
abstract class Sensor : SensorBase
{  
  public override void Readout()
  {
    Data[] data = ReadoutRaw();
    if (data.Length > 0)
    {
      curValue = data[data.Lenght – 1];
    }
  }
  public override string GetCurValString()
  {
    return curVal + “ “ + GetUnit();
  }
}
class TemperatureSensor : Sensor
{
  // Не переопределяем методы Readout и GetCurValString, 
  // будут использоваться реализации из класса Sensor, 
  // дополнительного кода в настоящем классе TemperatureSensor не потребуется.
}
class PressureSensor : Sensor
{
  // Не переопределяем методы Readout и GetCurValString, 
  // будут использоваться реализации из класса Sensor, 
  // дополнительного кода в настоящем классе PressureSensor не потребуется.
}
class VelocityHDSensor : Sensor 
{
  public override void Readout()
  {
    Data[] data = ReadoutRaw();
    // Рассчитываем среднее значение за последние 3 сек
    // и сохраняем его в curVal.
  }
  public override string GetCurValString()
  {
    return curVal + “ “ + GetUnit() + “ (avg)”;
  }
} 

Приведенное решение, во-первых, подходит и для первой, и для второй ситуации. Во-вторых, не требует изменения кода существующих производных классов при переопределении методов Readout и GetCurValString в новом производном. Однако остается две проблемы. Во-первых, переопределенные методы производного класса целиком заменяют методы базового класса. Так, при выводе отформатированной строки мы дублируем логику метода базового класса. Конкретно в этом случае она крайне проста, однако даже здесь, если, к примеру, потребуется для всех классов изменить форматирование и выводить единицу измерения в скобках, нам потребуется вносить изменение в двух местах: в базовом классе Sensor и в производном классе VelocityHDSensor. Во-вторых, мы вынуждены были объявить дополнительный базовый класс, хотя никакой семантики в нем нет: если датчики температуры, давления и скорости – это «частное» по отношению к датчику – «общему», то подобной семантической связи между Sensor и SensorBase нет, это лишь синтаксическая уловка. Как следствие, код становится сложнее для восприятия.

Какие возможности языка программирования могли бы решить эти две проблемы? Первая – возможность вызвать из переопределенного метода в производном классе тот же переопределенный метод в базовом классе. Вторая – возможность переопределять не только абстрактные, но и обычные, «конкретные» методы. Проанализируйте следующий код, демонстрирующий обе возможности:


abstract class Sensor
{
  // Помечая обычный, неабстрактный метод, ключевым словом virtual, 
  // мы разрешаем переопределить его в производных классах, 
  // как если бы он был абстрактным.
  public virtual void Readout()
  {
    Data[] data = ReadoutRaw();
    if (data.Length > 0)
    {
      curValue = data[data.Lenght – 1];
    }
  }
  
  public virtual string GetCurValString()
  {
    return curVal + “ “ + GetUnit();
  }
}
class TemperatureSensor : Sensor
{
  // Не переопределяем методы Readout и GetCurValString, 
  // будут использоваться реализации из класса Sensor, 
  // дополнительного кода в настоящем классе TemperatureSensor не потребуется.
}
class PressureSensor : Sensor
{
  // Не переопределяем методы Readout и GetCurValString, 
  // будут использоваться реализации из класса Sensor, 
  // дополнительного кода в настоящем классе PressureSensor не потребуется.
}
class VelocityHDSensor : Sensor 
{
  public override void Readout()
  {
    Data[] data = ReadoutRaw();
    // Рассчитываем среднее значение за последние 3 сек
    // и сохраняем его в curVal.
  }
  public override string GetCurValString()
  {
    // Ипользуя ключевое слово base, 
    // мы вызываем реализацию метода GetCurValString
    // из базового класса Sensor.
    // Использование имени GetCurValString без base 
    // приведет к рекурсивному вызову этого метода.
    return base.GetCurValString() + “ (avg)”;
  }
} 

Сформулируем определение.

Виртуальный метод (virtual) – метод, который может быть переопределен в производных классах.

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

В случае многоуровневого наследования логика вызовов методов такая же как для абстрактных методов: всегда вызывается самая последняя реализация в цепочке наследования для реального объекта. Продемонстрируем это сначала на приведенном выше коде. Положим, мы вызываем метод GetCurValString для объекта реального типа VelocityHDSensor через переменную типа Sensor:


Sensor sensor = new VelocityHDSensor();
Console.WriteLine (sensor.GetCurValString());

Будет вызвана самая последняя реализация в цепочке наследования для реального объекта, то есть реализация из класса VelocityHDSensor, далее, в коде этого класса мы используем переменную base, вызывая реализацию того же самого метода, но определенную в базовом классе. При этом используется та же логика: вызывается самая последняя (самая «частная») реализация в цепочке наследования, но не до реального объекта, а до ближайшего базового класса этого реального объекта. В нашем случае «цепочки» уже не будет, останется только класс Sensor:

Следующий рисунок демонстрирует поведение в аналогичной ситуации при многоуровневом наследовании:

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


class A
{
  public virtual void M() { Console.WriteLine (“A.M”); }
} 
class B : A
{
  public override void M() { Console.WriteLine (“B.M”); }
  
  public void Test()
  {
    // Далее в комментариях указывается результат выполнения
    // при вызове метода следующим образом:
    //   B obj = new C();
    //   obj.Test();
    // Вывод: C.M
    // (Так как реальный тип объекта this – С.)
    this.M();
    
    // Вывод: C.M
    // (Так как реальный тип объекта this – С.)
   
    ((A)this).M();
    // Вывод: A.M. 
    // Виртуальные и абстрактные методы обрабатываются,
    // как если бы реальный тип объекта base был A.
    base.M();
    // Следующие операции с ключевым словом base недопустимы и 
    // приведут к ошибке компиляции.
    // ((B)base).M(); 
    // Console.WriteLine ((A) this == base);
  }
}
class C : B
{
  public override void M() { Console.WriteLine (“C.M”); }
}

В заключение, рассмотрим еще один пример:


сlass A { public virtual M () { Trace.WriteLine (“A.M”); } }
abstract class B: A 
{ 
  public abstract override M () { Trace.WriteLine (“B.M”); 
}
class C : B { }
class D : B { public override M () { Trace.WriteLine (“D.M”); }
// Объясните результат выполнения следующего кода:
D d = new D();
d.M();  // Вывод: D.M
((B)d).M(); // Вывод:  D.M
((A)d).M(); // Вывод:  D.M
C c = new C();
c.M(); // Вывод:  B.M
((B)c).M(); // Вывод:  B.M
((A)c).M(); // Вывод:  B.M

Отметим, что абстрактные методы являются частным случаем виртуальных методов. Можно сказать, что абстрактный метод – это виртуальный метод без реализации. Поэтому, например, в С++, абстрактные методы также называются чистыми виртуальными методами (pure virtual).

Большинство современных языков программирования для обозначения рассмотренных понятий виртуальных и абстрактных методов используют именно термины «виртуальный» и «абстрактный». Также обратим внимание на то, что в C# мы должны явно помечать метод как виртуальный ключевым словом virtual, однако это не является общим правилом. Например, в Java все методы по умолчанию являются виртуальными.

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

§ 39. Класс object. В большинстве языков программирования есть специальный тип, который может хранить указатель на объект любого типа. Такой тип условно можно назвать базовым по отношению к любому пользовательскому или встроенному типу в том смысле, что преобразование между ним и другими типами выполняется по правилам преобразования между базовым и производным типом, рассмотренными в главе 3.2. В C# таким типом является тип object. В этом параграфе мы коротко рассмотрим несколько его ключевых и часто используемых возможностей.

Первая возможность, связанная с типом object – использование виртуальных методов, определённых в этом типе. Мы ограничимся рассмотрением только одного такого метода:


public virtual string ToString() { /* ... */ }

Реализация по умолчанию возвращает полное имя класса. Но мы можем переопределить его в своем классе:


namespace Geometry
{
  class Point
  {
    private float x;
    private float y;
    // Используем переопределение, 
    // хотя явно класс Point не наследуется ни от какого другого.
    // Неявное наследование от базового класса object.
    public override string ToString() 
    {
      return $”X = {x}, Y = {y}”; 
    }
  }
}
// Без переопределения:
Console.WriteLine (p.ToString()); // Вывод: Geometry.Point
// С переопределением:
Console.WriteLine (p.ToString()); // Вывод: X = 1, Y = 2

Вторая возможность, связанная с типом objectупаковка и распаковка значимых типов. Ранее мы уже обсуждали отличия значимых и ссылочных типов данных в C#. В некоторых языках мы можем использовать любой класс или как значимый, или как ссылочный. Например, так работает C++52. В некоторых языках эта логика скрывается, и тип фиксировано или всегда является значимым, или всегда является ссылочным. Так работает C#. Однако тот факт, что тип object является ссылочным и базовым по отношению ко всем другим типам, говорит нам, что мы можем преобразовать значимый тип, например, int к типу object:


int a = 123;
object o = a;
int b = (int) o;

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

В случае преобразования переменной значимого типа в object и обратно (на рисунке – слева) выполняется создание новой ссылочной переменной и копирование в нее значения исходной. В случае ссылочных переменных (на рисунке – справа), исходные данные остаются без изменений, меняются только ячейки для хранения адреса.

Преобразование переменной значимого типа в ссылочную переменную типа object называется упаковкой (boxing). Обратное преобразование называется распаковкой (unboxing).

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


int n = 123;
// string.Format (string format, params object[] args)
string message = string.Format (“Обработано {0} строк”, n);
Console.WriteLine (message); // Обработано 123 строк

Мы вернемся к вопросу упаковки и распаковки в главе 3.6 «Обобщенное программирование».

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

  1. Дайте определения следующим терминам, а также сопоставьте русские и английские термины: абстрактный метод, абстрактный класс, виртуальный метод, чистый абстрактный метод, переопределенный метод, упаковка, распаковка; abstract, virtual, pure virtual, override, boxing, unboxing.

  2. Зачем в абстрактном классе нужен конструктор, если экземпляр все равно никогда не создается?

  3. Можно ли при переопределении метода изменить его видимость?

  4. Напишите метод, создающий массив long[], содержащий миллион элементов и заполняющий его числами по порядку. Напишите аналогичный метод, создающий массив object[], содержащий миллион элементов и заполняющий его числами типа long по порядку. Сравните время выполнения методов с помощью класса Stopwatch и объясните результат.

  5. ** Изучите назначение виртуальных методов Equals и GetHash класса object.

  6. * В объектно-ориентированных языках существует возможность запретить наследование от заданного класса. Так в C#, мы можем пометить класс ключевым словом sealed (запечатанный). Чем можно объяснить, что в практике программирования, это ключевое слово значительно чаще используется в Java чем в C#?


Примечания:

  1. 47.  Мы вернемся к вопросу о недопустимости зависимости базовых классов от производных в § 43.

    ↩︎
  2. 48.  Отметим, что мы не можем объявить метод с одинаковой сигнатурой и базовом, и в производном классе (будет ошибка компиляции), не используя специальные механизмы переопределения, которые мы будем рассматривать далее. Поэтому в этом примере мы используем разные имена методов в производных классах (ReadoutTemperature, ReadoutPressure вместо Readout).

    ↩︎
  3. 49.  Не следует путать с абстрактными типами данных. Это не связанные термины. Как мы уже говорили в главе 2.1, термин «абстрактные типы данных» сегодня употребляется не так широко и обозначает типы данных, моделирующие в программе некоторые абстракции. Слово «абстрактные» в терминах «абстрактные методы» и «абстрактные классы» используется в несколько ином значения, обозначая, что методы или классы не вполне реализованы, в отличие от обычных, конкретных, методов и классов.

    ↩︎
  4. 50.  Заметим, что ключевым словом abstract может быть помечен и класс без абстрактных методов. В этом случае также запрещается создание экземпляров этого класса (но допускается создание экземпляров неабстрактных производных классов).

    ↩︎
  5. 51.  Термин «переопределение» используется, даже если в базовом классе метод только объявлен (приведена сигнатура без тела). Строго говоря, исходный английский термин override переводится как «замещение», то есть мы замещаем метод из базового класса (только объявленный или определенный с реализацией), новым методом (также только объявленным или определённым с реализацией). А термины «объявленный» и «определенный» используются непоследовательно.

    ↩︎
  6. 52.  Строго говоря, в С++ нет ссылочных типов, вместо этого мы создаем переменную указатель и присваиваем ей адрес объекта. Но эта схема в первом приближении полностью аналогична ссылочным типам C#. C++: Point* p = new Point(); C#: Point p = new Point(); Однако в C++ мы можем объявить переменную класса и как значимую: Point p; (аналогия: int x;). В этом случае память будет выделена в стеке метода и p будет обозначать не ячейку с адресом объекта, а сам объект.

    ↩︎

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

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

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