Интерфейсы C# — Самый подробный разбор

Возможно, тебе уже приходилось слышать про механизм множественного наследования. Это когда есть возможность создать класс производный от двух и более базовых классов. Так вот, забудьте о нем, CLR его не поддерживает совсем. НО! За-то есть возможность реализовать ограниченное множественное наследование через реализацию интерфейсов. Так вот, что такое интерфейсы, чем они отличаются от классов и особенности их применения мы и будем подробно рассматривать в этой статье и прикрепленном видео.

Подпишись на группу Вконтакте и Телеграм-канал. Там еще больше полезного контента для программистов.
А на YouTube-канале ты найдешь обучающие видео по программированию. Подписывайся!

Наследование классов и реализация интерфейсов. В чем разница?

В языке C# существует базовый класс, от которого наследуется любой другой. Это класс Object. В нем определены и реализованы 4 метода: Equals(), GetHashCode(), GetType() и ToString(). Это означает, что абсолютно любой класс наследует две вещи:

  1. Сигнатуры этих методов
  2. Реализацию этих методов

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

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

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

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

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

За счет того, что класс непосредственно в себе содержит определения методов, CLR способна различать к какому интерфейсу относится какой метод и избегать конфликтов. Благодаря этому мы можем реализовывать несколько интерфейсов, в отличии от множественного наследования. Но вот чем похожи наследование и реализация интерфейсов, так это возможностью подставлять экземпляры производного типа на место базовых. То есть наш любимый полиморфизм. Мы можем объявить переменную интерфейсного типа и в нее поместить экземпляр любого класса, который реализует этот интерфейс. Данная схема применяется очень часто, например, в DI-контейнерах или в mock-тестах.

Определение интерфейсов

Интерфейс — это поименованный набор сигнатур методов (в том числе событий, свойств и индексаторов, т.к. всё это по сути синтаксический сахар для методов). В интерфейсе нельзя определить конструкторы и поля, а также статические методы и константы (кстати, это ограничение языка, сама CLR на это способна).

Для создания интерфейса используется ключевое слово interface кто бы мог подумать. Например, вот несколько часто используемых стандартных интерфейсов из FCL:

public interface IDisposable {
    void Dispose();
}
public interface IEnumerable {
    IEnumerator GetEnumerator();
}
public interface IEnumerable<T> : IEnumerable {
    IEnumerator<T> GetEnumerator();
}
public interface ICollection<T> : IEnumerable<T>, IEnumerable {
    void Add(T item);
    void Clear();
    bool Contains(T item);
    void CopyTo(T[] array, int arrayIndex);
    bool Remove(T item);
    int Count { get; } 
    bool IsReadOnly { get; }
}

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

Для именования интерфейсов принято соглашение об использовании заглавной буквы I в начале имени. Как и всегда, это не является требованием системы, и программа сможет скомпилироваться как бы ты не назвал интерфейс. Но вот другие программисты могут и по голове настучать…

Интерфейсы могут наследоваться от других интерфейсов. Хотя Рихтер предпочитает называть это включением контрактов других интерфейсов. Например, рассмотрим приведенные ранее интерфейсы IEnumerable, IEnumerable<T> и ICollection<T>. Если какой-либо класс решит реализовать интерфейс ICollection<T> это будет означать:

  1. Этот класс обязан реализовать все методы определенные в интерфейсах IEnumerable, IEnumerable<T> и ICollection<T>
  2. Любой класс, который использует объект класса, реализующего интерфейс ICollection<T> также в праве ожидать и реализацию методов и IEnumerable, и IEnumerable<T>

Реализация интерфейсов

Ну а теперь самое время перейти к практике и посмотреть, как мы можем реализовать интерфейс в классе и использовать его в дальнейшем.

Для примера возьмем стандартный и широко используемый интерфейс IComparable<T>:

public interface IComparable<T> {
    int CompareTo(T other);
}

Данный интерфейс определяет механизм сравнения двух объектов. Теперь реализуем его в классе Point (весь исходный код доступен в GitHub):

InterfacesCSharp/ComparePoints
using System;
namespace ComparePoints
{
     //Объявляем что класс Point реализует обобщенный интерфейс IComparable<T>
     public class Point : IComparable<Point>
     {
         public int X { get; }
         public int Y { get; }

         public Point(int x, int y)
         {
             X = x;
             Y = y;
         }

         // Неявно реализуем метод из интерфейса IComparable<T>
         public int CompareTo(Point other)
         {
             return Math.Sign(Math.Sqrt(X * X + Y * Y) - 
               Math.Sqrt(other.X * other.X + other.Y * other.Y));
         }     

         public override string ToString()
         {
             return $"[{X}; {Y}]";
         }
     }
 }

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

using System;
using System.Collections.Generic;
namespace ComparePoints
{
    class Program
    {
        static void Main()
        {
            var points = new List
            {
                new Point(3, 3),
                new Point(1, 2)
            };

            if(points[0].CompareTo(points[1]) > 0)
            {
                var tempPoint = points[0];
                points[0] = points[1];
                points[1] = tempPoint;
            }

            Console.WriteLine("Упорядоченные по возрастанию от начала
                               координат точки:");

            foreach(var point in points)
            {
                Console.WriteLine(point);
            }

            Console.ReadLine();
        }
    }
}

Для того, чтобы проверить работоспособность кода, просто создадим две точки в коллекции в неправильном порядке, а затем выполним сортировку по возрастанию. Метод CompareTo() выполнит сравнение точек и так как они не упорядочены мы выполним их обмен местами, после чего выведем на консоль координаты возрастающих точек.

Для методов, реализующих интерфейс есть несколько требований:

  1. Метод должен быть публичным (public)
  2. Метод должен быть виртуальным (virtual)

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

То есть, если при реализации метода мы не добавляем модификатор virtual, то метод будет и виртуальным, и запечатанным. Если добавляем модификатор virtual — виртуальным и незапечатанным.

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

InterfacesCSharp/VirtualAndSealedImplementation
using System;
namespace VirtualAndSealedImplementation
{
    public class BaseSealed : IDisposable
    {
        // Базовый класс БЕЗ явного указания, 
        // что метод реализующий интерфейс виртуальный.
        // Из-за этого, метод всё равно будет виртуальным, 
        // но еще и запечатанным.
        public void Dispose()
        {
            Console.WriteLine(nameof(BaseSealed));
        }
    }
}
using System;
 namespace VirtualAndSealedImplementation
 {
     public class BaseUnsealed : IDisposable
     {
         // Базовый класс, явно указывающий, что метод виртуальный.
         // Из-за этого метод будет НЕ запечатанный и виртуальный.
         public virtual void Dispose()
         {
             Console.WriteLine(nameof(BaseUnsealed));
         }
     }
 }

Создадим два базовых класса, которые оба будут реализовывать интерфейс IDisposable. Но для класса BaseSealed мы не будем использовать при реализации метода ключевое слово virtual, в отличии от класса BaseUnsealed, где этот модификатор используется. Теперь перейдем к классам, которые будут наследовать данные базовые классы.

using System;
namespace VirtualAndSealedImplementation
{
    public class ChildSealed : BaseSealed
    {
        //public override void Dispose() // ERROR CS0506!
        //{
        //  Console.WriteLine(nameof(ChildSealed));
        //}
    }
}

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

using System;
namespace VirtualAndSealedImplementation
{
    public class ChildSealed : BaseSealed
    {
        new public void Dispose()
        {
            Console.WriteLine(nameof(ChildSealed));
        }
    }
}

Мы можем заместить реализацию базового метода с помощью ключевого слова new, но тогда метод Dispose() не будет относиться к интерфейсу IDispoible, а будет работать как самостоятельный метод.

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

using System;
namespace VirtualAndSealedImplementation
{
    public class ChildUnsealed : BaseUnsealed
    {
        public override void Dispose()
        {
            Console.WriteLine(nameof(ChildUnsealed));
        }
    }
}

Мы совершенно спокойно можем переопределить реализацию интерфейсного метода, так как он НЕ запечатан.

Но что нам делать, если нам нужно переопределить запечатанный у предка метод и при этом сохранить связь с интерфейсом? И для этого тоже есть решение:

using System;
namespace VirtualAndSealedImplementation
{
    public class ChildSealedOwnImplementation : BaseSealed, IDisposable
    {
        new public void Dispose()
        {
            Console.WriteLine(nameof(ChildSealedOwnImplementation));
            base.Dispose();
        }
    }
}

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

Подробнее о вызовах интерфейсных методов

А теперь самое время посмотреть на то, как происходит вызов методов, определенных в интерфейсах. Для примера возьмем стандартный строковый тип string. Если посмотреть на его определение, то мы можем увидеть, что он реализует набор различных интерфейсов: IComparable, ICloneable, IConvertible, IEnumerable, IComparable<String>, IEnumerable<char>, IEquatable<String>.

А также неявно наследует класс object. Это означает, что по умолчанию для типа string уже доступны для использования методы определенные в object, а самому типу необходимо будет реализовать все методы в представленных интерфейсах. Ну и естественно, при необходимости тип может определять свои собственные члены. И если мы обратимся к помощнику IntelliSence то мы увидим весь этот набор методов.

Помощник IntelliSence отображает весь список доступных методов, как определенных в самом классе (например, EndsWith()), в интерфейсах (CompareTo()) и в object (GetHashCode())

Но в CLR у нас также есть замечательная возможность определять переменные интерфейсного типа. В таком случае, эта переменная может содержать в себе, ну а если точнее, то ссылаться на экземпляр любого типа, который реализует этот интерфейс. В данном случае для использования будут доступны только те методы, которые определены в самом интерфейсе, плюс методы из object, так как любой тип унаследован от object и CLR может быть на 100% уверена, что эти методы там будут.

InterfacesCSharp/CallingInterfaceMethod
using System;
using System.Collections;
using System.Linq;
namespace CallingInterfaceMethod
{
    class Program
    {
        static void Main(string[] args)
        {
            string s = "code blog";
            DisplayAllMethods(s);

            ICloneable cloneable = s;
            DisplayAllMethods(cloneable);

            IComparable comparable = s;
            DisplayAllMethods(comparable);

            IEnumerable enumerable = (IEnumerable)comparable;
            DisplayAllMethods(enumerable);

            Console.ReadLine();
        }

        private static void DisplayAllMethods(T variable)
        {
            var uniqueMethodsName = typeof(T).GetMethods()
                .Select(n => n.Name).Distinct();

            Console.WriteLine($"Переменная типа {typeof(T)} (фактический тип
                              {variable.GetType()} содержит в себе 
                              следующие методы:");

            foreach(var method in uniqueMethodsName)
            {
                Console.WriteLine(method);
            }

            Console.WriteLine();
        }
    }
}

GetMethods() возвращает имена доступных методов типа. Но так как перегрузка метода считается за отдельный метод, то на выходе получается повторение некоторых методов несколько раз. И с помощью Select и Distinct я просто избавляюсь от повторов перегруженных методов.

В данном примере все переменные типов IComparable, ICloneable и IEnumerable на самом деле ссылаются на один и тот же экземпляр переменной string в куче. Но когда мы работаем с этой переменной через интерфейсные переменные, нам доступен только определенный в интерфейсе набор методов и методов из object (хотя они и не отображаются во время вывода на консоль. Таким образом именно тип переменной определяет, какие действия могут быть выполнены с объектом.

Интерфейсы C#
Вывод всех доступных методов для переменных различных интерфейсных типов, ссылающихся на одну и ту же строковую переменную

Кстати, нельзя забывать, что интерфейсы — это ссылочные типы, и так как значимые типы тоже могут реализовывать несколько интерфейсов, то к ним точно также можно обращаться через переменные интерфейса. НО! При этом будет выполняться упаковка, что может привести к снижению производительности. Об этом нужно помнить при работе с интерфейсами.

Явная и неявная реализация интерфейсных методов

Что же на самом деле происходит в CLR, когда мы загружаем тип? Для него создаются таблицы метаданных, одна из которых — это таблица методов. В нее добавляются данные обо всех методах, определенных в самом классе, а также обо всех методах, получаемых по иерархии наследования и обо всех интерфейсных методах.

InterfacesCSharp/ExtinctInterfaceMethodImplementation
using System;
namespace ExplicitInterfaceMethodImplementation
{
    public class SimpleType : IDisposable
    {
        // Неявная реализация метода.
        public void Dispose()
        {
            Console.WriteLine("SimpleType Dispose()");
        }
    }
}

Соответственно, для типа SimpleType должны быть созданы в метаданных:

  • Все экземплярные методы из object, так как он его неявно наследуется
  • Методы для всех унаследованных интерфейсов (в данном случае только IDisposible.Dispose())
  • Методы определенные в самом классе (в данном случае только Dispose())

Но так как CLR достаточно умна, она понимает, что метод Dispose() в SimpleType является реализацией метода интерфейса IDisposible.Dispose(), так как они имеют одинаковую сигнатуру. И соответственно в таком случае интерфейсный метод IDisposible.Dispose() и SimpleType.Dispose() указывают на одну и ту же фактическую реализацию. Но при желании, мы можем реализовать интерфейсный метод явно.

using System;
namespace ExplicitInterfaceMethodImplementation
{
    public class SimpleType : IDisposable
    {
        // Явная реализация метода.
        void IDisposable.Dispose()
        {
            Console.WriteLine("IDisposable Dispose()");
        }
    }
}

Как отличить явную реализацию (Explicit Interface Method Implementation, EIMI) от неявной? Нужно обратить внимание на два признака:

  • Перед именем метода указано имя самого интерфейса
  • У метода нельзя указывать модификатор доступа (он всегда private, чтобы к нему нельзя было случайно обратиться из вне)

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

using System;
namespace ExplicitInterfaceMethodImplementation
{
    class Program
    {
        static void Main(string[] args)
        {
            var simpleType = new SimpleType();

            // Вызов неявной реализации.
            simpleType.Dispose();

            // Вызов явной реализации.
            IDisposable disposable = simpleType;
            disposable.Dispose();

            Console.ReadLine();
        }
    }
}

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

В новых версиях языка C# существует реализация интерфейса по умолчанию. Подробнее это мы рассмотрим чуть позже, но для понимания следует знать, что мы можем создать интерфейс, и в него добавить метод с реализацией. И в дальнейшем обращаться к этой реализации можно будет только через интерфейсные переменные. Так вот, явная реализация интерфейса по сути является вот такой вот реализацией интерфейса по умолчанию, но располагается он не в интерфейсе, а в самом классе и применима только к нему одному. Но, по сути, она принадлежит именно интерфейсу, а не классу.

Обобщенные интерфейсы

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

В первую очередь хочется заметить, что существуют обобщенные и необобщенные версии часто используемых интерфейсов (IComparable, IEnumerable и т.д.). И самое важное помнить, что везде, где это возможно нужно использовать именно обобщенную версию. Они предоставляют несколько ключевых преимуществ, которые мы обсудим чуть позже. Но тогда возникает справедливый вопрос, зачем нужно было оставлять необобщенные версии? А ответ очень прост — для обратной совместимости. Поэтому, если придется столкнутся со старыми библиотеками, то в таком случае — да, нужно будет работать с необобщенными интерфейсами. Но если пишешь что-то новое — используй дженерики!

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

Ну а теперь к преимуществам. Если коротко — их три:

  • Более строгий контроль типов
  • Повышение производительности за счет избегания упаковки и распаковки
  • Многократная реализация обобщенных интерфейсов

Давай рассмотрим, данные преимущества обобщенных интерфейсов на примере:

InterfacesCSharp/GenericInterfaces

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

using System;
namespace GenericInterfaces
{
    // Реализуем интерфейс IComparable<> с двумя разными фактическими типами
    public sealed class Number : IComparable<int>, IComparable<string>
    {
        public int Value { get; }

        public Number(int value)
        {
            Value = value;
        }

        // Реализация интерфейса с int.
        public int CompareTo(int other)
        {
            return Value.CompareTo(other);
        }

        // Реализация интефейса со string.
        public int CompareTo(string other)
        {
            return Value.CompareTo(int.Parse(other));
        }
    }
}

А теперь рассмотрим примеры улучшений, которые предоставляют нам обобщенные интерфейсы:

using System;
namespace GenericInterfaces
{
    class Program
    {
        static void Main(string[] args)
        {
            Boxing(); // Демонстрация с упаковкой
            WithoutBoxing(); // Без упаковки

            Console.ReadLine();

            var number = new Number(42);
            // Сравниваем напрямую тип Number с помощью соответствующих 
               перегруженных методов обобщенного интерфейса
            Console.WriteLine(number.CompareTo(42));
            Console.WriteLine(number.CompareTo("42"));

            // Сравниваем с приведением к конкретному типу 
               обобщенного интерфейса
            IComparable comparableInt = number;
            comparableInt.CompareTo(42);

            IComparable comparableString = number;
            comparableString.CompareTo("42");

            Console.ReadLine();
        }

        private static void Boxing()
        {
            var x = 1;
            var y = 2;

            IComparable comparable = x;

            // Выполняется упаковка
               т.к. IComparable.CompareTo(object obj) в качестве аргумента 
               ожидает переменную типа object - ссылочный тип
               а мы передаем int - значимый тип. Это возможно, 
               но для работы необходимо упаковать переменную в куче.
            comparable.CompareTo(y);

            // Ошибка во время выполнения
               т.к. в качестве аргумента ожитадется object, то мы совершенно
               спокойно можем поместить любой тип, например строку,
               но во время выполнения CLR будет выполнять сравнение
               фактических типов int и string, что не возможно без приведения.
               Однако, во время написания кода 
               данная ошибка выявлена не будет.
            comparable.CompareTo("2");

            Console.WriteLine("Boxing");
        }

        private static void WithoutBoxing()
        {
            var x = 1;
            var y = 2;

            IComparable comparable = x;

            // Упаковка не выполняется
               т.к. IComparable.CompareTo(int other) в качестве
               аргумента ожидается int.
            comparable.CompareTo(y);

            // Выявление ошибки во время написания кода
               Статический анализатор Visual Studio в силах заметить 
               не соответствие, что в качестве аргумента ожидается int, 
               а мы передаем string.
            comparable.CompareTo("2"); // Error: CS1503!

            Console.WriteLine("Without boxing");
        }
    }
}

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

Обобщения и ограничения интерфейса

Как мы помним, применять обобщенные интерфейсы можно не только ко всему классу, но и к отдельным методам, и это тоже ведет к определенным преимуществам при использовании.

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

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

InterfacesCSharp/MethodConstraints
using System;
namespace MethodConstraints
{
    class Program
    {
        static void Main(string[] args)
        {
            var number = 5;
            var guid = new Guid();

            // Вызов метода с целым числом будет успешный,
               т.к. int реализует оба интерфейса IComparable и IConvertible
            Method(number);

            // Ошибка на этапе написания кода,
               т.к. Guid реализует только один интерфейс - IComparable, 
               но не реализует IConvertible
            Method(guid); // Error: CS0315!

            Console.ReadLine();
        }

        // Обобщенный метод, который требует чтобы тип переменной item
           обязательно реализовывал интерфейсы IComparable и IConvertible.
           При работе с этим методом упаковка выполняться не будет.
           А несоответствие типа Guid ограничениям будет выявлено
           на этапе написания кода
        private static void Method(T item) where T : IComparable, IConvertible
        {
            Console.WriteLine("Generic Method");
        }

        // Необобщенный метод, который в качестве аргумента
           принимает интерфейс.
           В данном случае у нас меньше возможностей, 
           поскольку мы можем наложить ограничение на использование типов 
           реализующих только один интерфейс.
           Кроме того, при работе со значимыми типами 
           будет выполняться упаковка, т.к. IComparable - ссылочный тип,
           а несоответствие типа будет выявлено только на этапе выполнения.
        private static void Method(IComparable item)
        {
            Console.WriteLine("Method");
        }
    }
}

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

Реализация нескольких интерфейсов с одинаковыми сигнатурами и именами методов

Достаточно часто при создании классов приходится реализовывать различные интерфейсы. И иногда можно столкнуться с такой неприятной особенностью, что два совершенно разных интерфейса содержат методы с одинаковыми именами и сигнатурами. Случается такое достаточно редко, но это возможно, поэтому нужно быть готовым и к таким трудностям. В таком случае придется реализовывать интерфейсы явно. Как всегда, рассмотрим на примере.

InterfacesCSharp/NameConflict
namespace NameConflict
{
    public interface IRestaurant
    {
        void GetMenu();
    }
}
namespace NameConflict
{
    public interface IWindow
    {
        void GetMenu();
    }
}

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

  • IRestaurant — список блюд доступных в ресторане
  • IWindow — контекстное меню в окне приложения

И потом получается так, что один и тот же класс реализует оба этих интерфейса:

using System;
namespace NameConflict
{
    public sealed class Pizzeria : IWindow, IRestaurant
    {
        // Явная реализация интерфейса IWindow
        void IWindow.GetMenu()
        {
            Console.WriteLine("IWindow.GetMenu()");
        }

        // Явная реализация интерфейса IRestaurant
        void IRestaurant.GetMenu()
        {
            Console.WriteLine("IRestaurant.GetMenu()");
        }

        // Собственная реализация метода GetMenu. 
           Никак не связана ни с одним из интерфейсов.
        public void GetMenu()
        {
            Console.WriteLine("Pizzeria.GetMenu()");
        }
    }
}

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

using System;
namespace NameConflict
{
    class Program
    {
        static void Main(string[] args)
        {
            var pizzeria = new Pizzeria();

            // По умолчанию будет вызываться собственный метод класса, 
               а интерфейсные методы игнорироваться.
               Интересная особенность, если из класса Pizzeria удалить
               собственный метод GetMenu(), то он будет 
               вообще не доступен у экземпляра класса
               т.к. мы помним, что явные реализации на самом деле
               принадлежат интерфейсу, а не классу, в котором они находятся.
            pizzeria.GetMenu();

            // Теперь приведя экземпляр класса и интерфесному типу 
               мы будем вызывать именно явную реализацию метода 
               для интерфейса IWindow
            IWindow window = pizzeria;
            window.GetMenu();

            // Чуть более короткая форма записи приведения экзепляра к 
               интерфейсному типу IRestaurant и вызова 
               явной реализации для этого интерфейса
            ((IRestaurant)pizzeria).GetMenu();

            Console.ReadLine();
        }
    }
}

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

Реализация интерфейсов по умолчанию

Interfaces.Csharp.DefaultInterfaceImplementation
using System;
namespace DefaultInterfaceImplementation
{
    public interface IRestaurant
    {
        // В интерфейсе объявлен метод и для него 
           добавлена реализация по умолчанию.
        void GetMenu()
        {
            Console.WriteLine("IRestaurant.GetMenu() - default 
                               implementation");
        }
    }
}

Создадим интерфейс и прямо внутри интерфейсы напишем реализацию метода по умолчанию. Затем будет реализовывать этот интерфейс используя разные подходы.

namespace DefaultInterfaceImplementation
{
    // Без собственной реализации в классе
    public sealed class Pizzeria : IRestaurant {
    }
}

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

using System;
namespace DefaultInterfaceImplementation
{
    public sealed class BugrerBar : IRestaurant
    {
        // Явная реализация
        void IRestaurant.GetMenu()
        {
            Console.WriteLine("BugrerBar.GetMenu() - explicit 
                               implementation");
        }
    }
}

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

using System;
namespace DefaultInterfaceImplementation
{
    public sealed class ChineseRestaurant : IRestaurant
    {
        // Неявная реализация.
        public void GetMenu()
        {
            Console.WriteLine("ChineseRestaurant.GetMenu() - own 
                               implementation");
        }
    }
}

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

using System;
namespace DefaultInterfaceImplementation
{
    class Program
    {
        static void Main(string[] args)
        {
            var pizzeria = new Pizzeria();
            //pizzeria.GetMenu(); // Метод GetMenu() не доступен
            ((IRestaurant)pizzeria).GetMenu(); // Реализация по умолчанию

            var burgerBar = new BugrerBar();
            //burgerBar.GetMenu(); // Метод GetMenu() не доступен 
            ((IRestaurant)burgerBar).GetMenu(); // Явная реализация

            var chineseRestaurant = new ChineseRestaurant();
            chineseRestaurant.GetMenu(); // Доступно
            ((IRestaurant)chineseRestaurant).GetMenu(); // Неявная реализация

            Console.ReadLine();
        }
    }
}
В свежих версиях C# появилась возможность реализовывать методы прямо внутри интерфейсов

Совершенствование контроля типов за счет явной реализации интерфейсных методов

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

InterfacesCSharp/EimiForNoneGenericInterfaces

Для примера возьмем стандартный необобщенный интерфейс IComparable

public interface IComparable {
    int CompareTo(object other);
}

И реализуем его в тривиальном значимом типе:

using System;
namespace EimiForNoneGenericInterfaces
{
    public struct SomeValueType : IComparable
    {
        public int Value { get; }

        public SomeValueType(int value)
        {
            Value = value;
        }

        public int CompareTo(object other)
        {
            return Math.Sign(Value - ((SomeValueType)other).Value);
        }
    }
}

При работе с этим типом мы можем столкнуться с нежелательным поведением системы — падение производительности за счет упаковки, а также возможность совершить ошибку при написании кода, так как не соответствие типов не будет определяться синтаксическим анализатором Visual Studio.

А теперь попробуем решить обе этих проблемы одним махом с помощью явной реализации интерфейсных методов:

using System;
namespace EimiForNoneGenericInterfaces
{
    public struct EimiValueType : IComparable
    {
        public int Value { get; }

        public EimiValueType(int value)
        {
            Value = value;
        }

        // Собственный метод класса, не связанный с интерфейсом IComparable
           и в качестве аргумента принимающий EimiValueType, а не object
        public int CompareTo(EimiValueType other)
        {
            return Math.Sign(Value - other.Value);
        }

        // Явная реализация интерфейса IComparable, 
           которая вызывает внутренний метод
        int IComparable.CompareTo(object other)
        {
            // Добавить проверку на соответствие типа.
            return CompareTo((EimiValueType)other);
        }
    }
}

Для этого в классе мы будем использовать сразу два метода CompareTo(). Собственная реализация будет принимать в качестве аргумента не object, а конкретный значимый тип EimiValueType, но это не соответствует контракту интерфейса IComparable. Поэтому нам и нужен второй метод CompareTo(), который будет соответствовать сигнатуре заявленной в интерфейсе, но реализовывать его мы будем явно.

using System;
namespace EimiForNoneGenericInterfaces
{
    class Program
    {
        static void Main(string[] args)
        {
            var value = new SomeValueType(0);
            var obj = new object();

            // В данном случае мы видим два нежелательных поведения:
               выполняется упаковка и во время написания кода 
               не выводится сообщение об несовместимости типов.
               Ошибка будет выявлена только во время выполнения приложения.
            Console.WriteLine(value.CompareTo(value)); // Упаковка
            Console.WriteLine(value.CompareTo(obj)); // Несоответствие типов

            Console.ReadLine();
            
            // Используем улучшенную версию
            var eimi = new EimiValueType(0);

            Console.WriteLine(eimi.CompareTo(eimi)); // Нет упаквки
            //Console.WriteLine(eimi.CompareTo(obj)); // Ошибка видна сразу

            Console.ReadLine();

            IComparable comparable = eimi; // Первая упаковка
            Console.WriteLine(comparable.CompareTo(eimi)); // Вторая упаковка
            
            // Безопасность типов опять не соблюдается. 
               Ошибка видна только во время выполнения
            Console.WriteLine(comparable.CompareTo(obj)); 

            Console.ReadLine();
        }
    }
}

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

Опасность явной реализации интерфейсных методов

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

Путаница в документации или еще отсутствие

Например, обратимся к официальной документации Microsoft, которая описывает тип int.

Информация о типе int

И мы чуть ли не в первой строчке видим, что данный тип реализует интерфейс IConvertible. Но при этом, когда мы пытаемся вызвать соответствующие методы у экземпляра этого типа — ничего не выходит

InterfacesCSharp/DangerousOfEimi
private static void ConvertibleInt()
{
    var i = 5;
    var temp = i.ToSingle(null); // ToSingle() - не существует
}

Для того, чтобы решить подобную проблему, нам необходимо выполнить приведение переменной к интерфейсному типу, так как тип int реализует интерфейс IConvertible явно. И разработчику, который не знаком с подобной особенностью приходится догадываться, как так получается, что тип реализует интерфейс, а вызвать методы, однако, не получается. И даже мой любимый IntelliSense — в данном случае не помогает.

private static void Convertible()
{
    int i = 5;
    var temp = ((IConvertible)i).ToSingle(null); 
    // ToSingle() работает, но будет выполняться упаковка
}

Но даже подобный ход является далеко не оптимальным решением из-за второй проблемы

Упаковка

Если ты внимательно читал, то уже мог догадаться о другой проблеме, которая возникает, когда мы пытаемся решить первую. Так как int — это структура, а следовательно — значимый тип, при приведении к интерфейсу IConvertible будет выполняться упаковка, что негативно скажется на быстродействие и потребление ресурсов приложения.

EIMI нельзя вызывать из производных типов

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

using System;
namespace DangerousOfEimi
{
    public class Base : IComparable
    {
        int IComparable.CompareTo(object obj)
        {
            Console.WriteLine("Base CompareTo()");
            return 0;
        }
    }
}

Если попытаться обратиться к базовой реализации интерфейса base.CompareTo(o), то данный метод просто будет не доступен от слова совсем.

using System;
namespace DangerousOfEimi
{
    public class Derived : Base, IComparable 
    {
        public int CompareTo(object o)
        {
            return base.CompareTo(o); // Base не содержит метод CompareTo()
        }
    }
}

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

using System;
namespace DangerousOfEimi
{
    public class Derived : Base, IComparable
    {
        public override int CompareTo(object o)
        {
            Console.WriteLine("Derived CompareTo()");

            // Nожно попробовать привести тип к интерфейсу.
               но это приведет к бесконечной рекурсии
            IComparable comparable = this;
            return comparable.CompareTo(o);
        }
    }
}

Один из вариантов решения данной проблемы — для класса Derived не реализовывать интерфейс IComparable самостоятельно, но иногда это необходимо. В таком случае есть другой подход — нам понадобится изменить и базовый класс и наследник.

using System;
namespace DangerousOfEimi
{
    public class Base : IComparable
    {
        int IComparable.CompareTo(object obj)
        {
            Console.WriteLine("Base CompareTo()");
            return CompareTo(obj);
        }

        public virtual int CompareTo(object o)
        {
            Console.WriteLine("Base virtual CompareTo()");
            return 0;
        }
    }
}
using System;
namespace DangerousOfEimi
{
    public class Derived : Base
    {
        public override int CompareTo(object o)
        {
            Console.WriteLine("Derived CompareTo()");
            return base.CompareTo(o);
        }
    }
}

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

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

Базовый класс или интерфейс C#?

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

  1. Какой тип мы создаем? Если это структура, то ответ на вопрос однозначный — используйте интерфейс. Потому что значимые типы неявно наследуются от типа ValueType, а следовательно, не могут быть унаследованы от другого класса, ведь тогда получается множественное наследование, которое запрещено в C#. Для ссылочных типов уже следует обращать внимание и на остальные параметры.
  2. Связь потомка с предком. Здесь тоже все достаточно просто, если мы хотим получить множественное наследование — то необходимо использовать интерфейсы, ну или как максимум базовый тип и интерфейсы.
  3. Простота использования. Важно помнить, что при реализации интерфейса в каждом случае придется реализовывать методы с нуля или что еще хуже — дублировать код. Базовый класс позволяет определить поведение по умолчанию сразу для всех наследников без необходимости писать дополнительный код. А так как программисты люди ленивые — это очень сильный аргумент в пользу базовых классов.
  4. Управление версиями. Если вдруг возникла необходимость добавить метод в БАЗОВЫЙ контракт (не важно базовый класс или интерфейс), то это в любом случае повлияет на всех наследников, что логично. Но вот только степень влияния оказывается разной. Для интерфейса придется менять и добавлять реализацию в каждого наследника, а следовательно, и перекомпилировать. Особенно сильно это влияет, если меняется интерфейс в библиотеке, которая используется в различных проектах. Для базового класса — такой проблемы нет. Наследники просто принимают изменения базового типа и их даже не нужно перекомпилировать.

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

Как использовать базовый абстрактный класс C#

Базовые классы хорошо использовать для экономии времени при реализации большого количества схожих по поведению классов. Если несколько типов полностью совпадают в своем поведении или отличаются совсем немного, то нет смысла реализовывать их каждый раз с нуля. К тому же, если потребуется внести изменения, их достаточно будет сделать только в одном месте. Примерами подобных классов могут быть потоки, которые наследуются от базового класса Stream или элементы интерфейса Windows Forms, которые являются потомками класса Control.

InterfacesCSharp/BaseClassVsInterface
namespace BaseClassVsInterface
{
    public abstract class AnimalBase
    {
        protected string _speech = "";
        public string Say()
        {
            return _speech;
        }
    }
}

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

namespace BaseClassVsInterface
{
    public class Cat : AnimalBase
    {
        public Cat()
        {
            _speech = "Мяy";
        }
    }
}
namespace BaseClassVsInterface
{
    public class Dog : AnimalBase
    {
        public Dog()
        {
            _speech = "Гав-гав";
        }
    }
}

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

private static void BaseClasses() 
{ 
    var animals = new List<AnimalBase> 
    { 
        new Cat(), 
        new Dog() 
    };   

    foreach (var animal in animals) 
    { 
        Console.WriteLine($"{animal.GetType().Name} говорит {animal.Say()}");
    } 
}

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

Как использовать интерфейсы C#

Интерфейсы же целесообразно использовать, если каждый из наследников будет содержать свою собственную логику, которая может совершенно отличаться или лишь слегка пересекаться с другими наследниками. Несмотря на то, что они предоставляют одинаковые возможности, но добираются результатов различными подходами и не имеют совместно используемого когда. Также если мы хотим большей независимости или боимся, что изменения в одном классе могут повлиять на другой. Примером могут служить коллекции. Несмотря на то, что каждая коллекция List, Queue, Dictionary, Stack предоставляет схожие функции для работы с наборами элементов, каждая из них уникально реализована и ведет себя по-своему, поэтому и базируются они на интерфейсах. При этом мы можем, например добавлять и удалять элементы, даже не зная, какая именно это коллекция.

namespace BaseClassVsInterface
{
    public interface IFigure
    {
        double GetArea();
    }
}

Рассмотрим другой синтетический пример. У нас есть интерфейс для всех геометрических фигур, которые определяет метод для вычисления пощади.

using System;
namespace BaseClassVsInterface
{
    public class Circle : IFigure
    {
        public double Radius { get; }

        public Circle(double radius)
        {
            if (radius < 0) 
                throw new ArgumentException("Радиус < 0", nameof(radius));
            Radius = radius;
        }

        public double GetArea()
        {
            return Math.PI * Radius * Radius;
        }
    }
}
using System;
namespace BaseClassVsInterface
{
    public class Square : IFigure
    {
        public double Lenght { get; }

        public Square(double lenght)
        {
            if (lenght < 0) 
                throw new ArgumentException("Длина < 0", nameof(lenght));
            Lenght = lenght;
        }

        public double GetArea()
        {
            return Lenght * Lenght;
        }
    }
}

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

private static void Interfaces()
{
    var figures = new List
    {
        new Circle(5),
        new Square(5)
    };

    foreach (var figure in figures)
    {
        Console.WriteLine($"{figure.GetType().Name} = {figure.GetArea()}");
    }
}

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

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

Interface ⇽ BaseClass ⇽ ParticularClass

namespace BaseClassVsInterface
{
    interface Interface
    {
        void DoWork();
    }
}
using System;
namespace BaseClassVsInterface
{
    public abstract class BaseClass : Interface
    {
        public void DoWork()
        {
            Console.WriteLine("Do work");
        }
    }
}
namespace BaseClassVsInterface
{
    public class PacticularClass : BaseClass {
    }
}

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

Советую прочитать предыдущую статью — 7 обязательных навыков дата-сайентиста
А также подписывайтесь на группу ВКонтактеTelegramИнстаграм и YouTube-канал. Там еще больше полезного и интересного для программистов.