3.4. Интерфейсы

§ 40. Определение. Неформально можно сказать, что интерфейс – это аналог абстрактного класса, но 1) который содержит только абстрактные (без реализации)53 открытые методы, 2) от которого допускается множественное наследование, 3) все методы которого должны быть переопределены в первом производном классе. Рассмотрим эти особенности на следующем примере.


interface IShape
{
  // Все методы – открытые, 
 // явно ключевое слово public не указываются.
 // Все методы – без реализации.
  double GetSquare();
  double GetPerimeter();
}
interface IPointCollection
{
  Point[] GetPoints();
}
// Множественное наследование.
abstract class Polygon : IShape, IPointCollection
{
  public double GetPerimeter()
  {
    // ...
  }
  // Все методы интерфейса должны быть явно переопределены
  // именно в этом классе, поэтому при необходимости
  // определить реализацию в производном,
  // переопределяем здесь как абстрактный.
  public abstract double GetSquare();
  public Point[] GetPoints()
  {
    // ...
  }
}
class Triangle : Polygon
{
  public override double GetSquare()
  {
    // ...
  }
}

Разберем синтаксис: при объявлении интерфейса используется ключевое слово interface вместо class, а при объявлении методов интерфейса не используется модификатор видимости, так как все методы всегда открыты. Также не нужно использовать ключевые слова abstract в интерфейсе и override в производных классах. Для записи множественного наследования наследуемые интерфейсы перечисляются через запятую. Так как все методы интерфейса должны быть переопределены в первом производном классе, то метод GetSquare в первом наследуемом классе (Polygon) мы переопределяем как абстрактный, а в классе Triangle мы переопределяем уже этот абстрактный метод, приводя реализацию.

На следующем рисунке приведена диаграмма UML: интерфейс изображается как класс, но помечается текстом <<interface>>; отношение реализации (наследования) интерфейса изображается прерывистой линией со стрелкой как при наследовании.

В C# принято соглашение именования интерфейсов с буквы I. Отметим, что, например, в Java, наоборот, не рекомендуется использовать I в наименовании интерфейсов и они именуются как классы.

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

Интерфейс (interface) – именованный перечень сигнатур открытых методов, который может быть унаследован другим интерфейсом или классом; первый наследующий класс должен переопределить все методы интерфейса; допускается множественное наследование от интерфейсов. О классе, наследующем интерфейс, говорят, что он реализует (implement) интерфейс и его методы.

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

§ 41. Инверсия зависимости и модульные тесты. Автоматическое тестирование кода – область, где широко применяются интерфейсы. Это большая тема. Вкратце, идея заключается в том, чтобы написать специальный код (метод), который будет проверять корректность выполнения отдельных методов приложения. Такой специальный код (метод) называется модульным тестом (unit test).

Рассмотрим следующий пример. Положим, мы пишем программу, которая анализирует траектории движения общественного транспорта. На автобусах стоят устройства местоопределения ГЛОНАСС, которые по сети GSM присылают данные о текущем местоположении на сервер. Специальное приложение на сервере получает эти данные и записывает в базу данных. Наше приложение по запросу пользователя считывает пройденный путь из базы данных и выполняет некоторый анализ, например, рассчитывает длину пути за последний час.


// Местоположение.
public class Point
{
  public DateTime Timestamp;
  public float X;
  public float Y;
}
// Класс, выполняющий чтение данных из БД.
public class DataProvider
{
  // Путь, пройденный автобусом (с идентификатором busId)
  // за указанное число (depthInHours) часов.
  public Point[] GetPath (long busId, float depthInHours)
 {
   //... Обращение к базе данных, чтение данных.
 }
}
    // Анализ.
public class Analytics
{
  private DataProvider dataProvider;
  public Analytics (DataProvider dataProvider)
  {
    this.dataProvider = dataProvider;
  }
  public float Distance (long busId, float depthInHours)
  {
    Point[] path = dataProvider.GetPath(busId, depthInHours);
    // ... Расчет длины пути.
  }
}

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


public class AnalyticsTest
{
  public bool DistanceTest()
  {
    DataProvider dataProvider = new DataProvider();
    Analytics analytics = new Analytics(dataProvider);
    float distance = analytics.Distance(100, 8);
    return distance == 44.5;
  }
}

Мы запрашиваем расстояние и сравниваем с нам известным значением. В приведенном примере мы знаем, что для автобуса с кодом 100 за последние 8 часов расстояние составляет 44,5. Но откуда мы это можем знать? Принципиальная проблема в том, что тест оказывается зависим от текущего состояния базы данных и мы заранее не можем знать «правильного» ответа. Как вариант, мы можем в базе создавать каждый раз фиктивный объект, записывать путь и получать его значение, но это обычно неудачный подход, по крайней, мере по двум причинам. Во-первых, часто сложность написания теста повышается настолько, что использовать такой подход к тестированию становится нецелесообразным. Во-вторых, мы в результате тестируем не только метод Distance, но значительно большую часть кода, связанную с записью и выборкой данных из базы.

Общее решение – для которого и используются интерфейсы – заключается в создании класса-заглушки (stub), который имитирует поведение объекта dataProvider без реального обращения к базе данных, тем самым разрывая зависимость от источника данных.


public interface IDataProvider
{
  Point[] GetPath (long busId, float depthInHours);
}
public class DataProvider : IDataProvider { /* … */ }
// Класс, заглушка, имитирующий поведение «настоящего» класса DataProvider.
// Здесь обращения к БД не происходит.
public class DataProviderStub : IDataProvider
{
  public Point[] GetPath (long busId, float depthInHours)
  {
    Point[] path = new Point[5];
    path[0] = new Point(DateTime.Now – depthInHours, 100, 100);
    // …
    return path;
  }
}
public class AnalyticsTest
{
  public bool DistanceTest()
  {
    IDataProvider dataProvider = new DataProviderStub ();
    Analytics analytics = new Analytics(dataProvider);
    float distance = analytics.Distance(100, 8);
    return distance == 44.5;
   }
}

При запуске теста внутри класса Analytics вызывается метод GetPath для переданного объекта dataProvider, который является объектом-заглушкой и всегда без обращения к базе возвращает один и тот же путь.

Таким образом, интерфейс позволяет подменить объекты, создающие зависимости и затрудняющие тестирование. На следующем рисунке показана диаграмма классов для варианта без интерфейса (вверху) и с интерфейсом (внизу).

Рассмотренный пример демонстрирует широко применяемую практику инверсии зависимости (dependency inversion). Мы не будем рассматривать этот вопрос подробно, но общая идея заключается в следующем. Положим, у нас есть классы A и B. При этом класс A зависит от класса B, например, обращается к некоторым методом из класса B. Но мы хотим избавиться от этой зависимости, например, чтобы иметь возможность безопасно изменять или даже заменять класс B на другой. В рассмотренном примере Analytics – это класс A. Исходно он зависел от DataProvider – это класс B. Чтобы исключить эту зависимость мы создаем дополнительный интерфейс (или абстрактный класс) BaseB. В результате A зависит от BaseB, B зависит (реализует или наследует) от BaseB, но A и B не зависят друг от друга. В рассмотренном примере IDataProvider – это BaseB.

Таким образом, рассмотренное решение эффективно не только для реализации модульных тестов, но и, в целом, делает архитектуру более гибкой, снижая зависимость частей программы друг от друга. Более того, эта возможность часто отмечается как ключевая возможность объектно-ориентированного программирования: «That is the power that OO provides. That’s what OO is really all about – at least from the architect’s point of view (dependency inversion). This is one reason that object-oriented development has become such an important paradigm in recent decades.» [Мартин 11]

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

  1. Что такое интерфейс? В чем отличие интерфейса от абстрактного класса?

  2. В стандартной библиотеке C# определен интерфейс ICloneable с единственным методом object Clone(). Рассмотрим следующий код:


class A : ICloneable
{
  int x;
  public object Clone()
  {
    A copy = new A();
    copy.x = x;
    return x;   
  }
}
class B : A
{
   int y;
}
// Клонирование объекта типа A
A a = new A();
A aCopy = (A)a.Clone();
B b = new B();
// Ошибка, т.к. метод Clone возвращает экземпляр класса A.
B bCopy = (B)b.Clone();

Каким образом нужно доработать этот код, чтобы мы могли вызывать метод Clone от интерфейса как для a, так и для b и избежать ошибки в последней строке?

  1. Положим в предыдущей задаче метод A.Clone имеет следующую реализацию:


public object Clone()
{
  return this.MemberwiseClone();
}

Как при этом изменится решение задачи и изменится ли оно?

  1. ** Познакомьтесь с инструментами модульного тестирования MSTest или NUnit.


Примечания:

  1. 53.  Некоторые языки с оговорками допускают задание реализации метода в интерфейсе. Например, C# позволяет задавать реализацию по умолчанию (явный способ) или реализацию через механизм расширений (косвенный способ). Однако здесь при первом знакомстве с интерфейсами мы не будем рассматривать эти возможности.

    ↩︎

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

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

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