Выбор между интерфейсами и классами

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

Если пример с зоопарком не дает четкого ответа на вопрос, когда стоит использовать наследование, а когда реализацию наследования, примите во внимание следующие соображения. Можно легко объявить класс ZooFlyer, унаследованный от ZooDweller, а затем наследовать Bird от ZooFlyer.

Однако что если будет введен класс Zoolnsect, унаследованный от ZooDweller? Как тогда объявить ZooFruitFly? В конце концов, в С# множественное наследование не допускается, и потому ZooFruitFly не может одновременно наследоваться от Zoolnsect и ZooFlyer. Если возникают ситуации подобного рода, стоит пересмотреть иерархию классов, поскольку она, возможно, излишне усложнена.

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

С ростом популярности СОМ некоторые разработчики сделали ошибочный вывод, что единственный способ определения контракта состоит в определении интерфейса. К такому заключению легко прийти, переходя из среды СОМ в среду С# — просто потому, что базовым строительным блоком в СОМ является интерфейс, а С# и .NET поддерживают интерфейсы естественным образом. Однако такой вывод может оказаться опасным при проектировании.

Если вы знакомы с СОМ и имеете опыт разработки серьезных проектов в рамках этой технологии, то наверняка реализовывали объекты СОМ на языке С++. Возможно, вы даже пользовались библиотекой активных шаблонов (Active Template Library — ATL), чтобы оградить себя от сложности решения низкоуровневых задач при разработке для СОМ. Так каким же образом в С++ моделируются интерфейсы СОМ? Ответ: с помощью абстрактных классов.

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

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

1
2
3
4
5
6
7
8
9
public interface IMyOperations {
void Operationl ();
void Operation2 ();
}
// Клиентский класс
public class ClientClass : IMyOperations {
public void Operationl() { }
public void Operation2()  { }
}

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

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

Никогда не модифицируйте уже опубликованное объявление интерфейса.

Справиться с этой проблемой можно было бы, определив полностью новый интерфейс, скажем, Imy0perations2.

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public interface IMyOperations
void Operationl();
void 0peration2();
}
public interface IMy0perations2 {
void Operationl ();
void 0peration2();
void 0peration3 ();
}
// Клиентский класс
public class ClientClass : IMyOperations, IMy0perations2 {
public void Operationl () { }
public void 0peration2() { }
public void 0peration3()  { }
}
public class AnotherClass {
public void DoWork( IMyOperations ops )  { }
}

Модификация ClientClass для поддержки новой операции из IMy0perations2 не особенно трудна, но как быть с существующим кодом вроде показанного в AnotherClass? Проблема в том, что метод DoWork принимает тип IMyOperations. Для того чтобы он мог вызывать новый метод 0peration3, нужно изменить прототип DoWork, или же код внутри него должен выполнять приведение параметра к типу I0perations2, что может дать сбой во время выполнения.

Раз вы хотите, чтобы компилятор мог перехватывать как можно больше ошибок, связанных с типами, лучше все-таки изменить прототип DoWork, чтобы он принимал тип IMyOperations2.

Если первоначальный интерфейс IMyOperations определен в поддерживающей версии, строго именованной сборке, то можно создать новый интерфейс с тем же именем в новой сборке, но только с отличающейся версией сборки. Хотя в .NET Framework это поддерживается явно, не следует так поступать без тщательного обдумывания, поскольку введение двух интерфейсов IMyOperations, отличающихся только номером версии содержащей их сборки, может сбить с толку клиентов.

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

1
2
3
4
5
6
7
8
9
10
11
public abstract class MyOperations {
public virtual void Operationl ()  { }
public virtual void 0peration2()  {
// Клиентский класс
public class ClientClass : MyOperations {
public override void Operationl () { }
public override void 0peration2()  { }
}
public class AnotherClass {
public void DoWork( MyOperations ops )  { }
}

MyOperations — это базовый класс для ClientClass. Его преимущество состоит в том, что при необходимости он может содержать реализацию по умолчанию. В противном случае виртуальные методы MyOperations могут быть объявлены как abstract, поскольку для клиентов не имеет смысла создавать экземпляры MyOperations. Теперь предположим, что необходимо добавить новый метод 0peration3 в MyOperations, не затрагивая существующих клиентов.

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public abstract class MyOperations
{
public virtual void Operationl ()  { }
public virtual void 0peration2()  { }
public virtual void 0peration3()  {
// Новая реализация по умолчанию
}
}
// Клиентский класс
public class ClientClass : MyOperations {
public override void Operationl () { }
public override void 0peration2()  { }
}
public class AnotherClass {
public void DoWork( MyOperations ops )  {
ops.0peration3 ();
}
}

Обратите внимание, что добавление MyOperations.Operation3 не потребует никаких изменений в ClientClass, и AnotherClass . DoWork может вызывать 0peration3, не внося никаких изменений в объявление метода. Такая техника, правда, не лишена своих недостатков. Вы ограничены тем фактом, что управляемая исполняющая система допускает наследование класса только от одного базового класса.

Поскольку ClientClass наследует MyOperations, чтобы получить функциональность, он использует свой единственный билет на наследование. Это может наложить сложные ограничения на клиентский код. Например, что если одному из клиентов нужно создать объект для использования с .NET Remoting? В таком случае класс должен наследоваться от MarshalByRefObject.

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

• Если моделируется отношение “является” (is-a), использовать класс. Если контракт можно осмысленно назвать именем существительным, то, вероятно, его стоит моделировать с помощью класса.

• Если моделируется отношение “реализует” (implements), использовать интерфейс. Если контракт можно осмысленно назвать именем прилагательным, как если бы это было некоторое качество, то, вероятно, вы необходимо моделировать с помощью интерфейса.

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

• По возможности отдавать предпочтением классам перед интерфейсами. Это может поспособствовать расширяемости.

Примеры применения описанных приемов повсеместно встречаются в библиотеке базовых классов .NET Framework (Base Class Library — BCL). Старайтесь использовать их и в собственном коде.

Вы можете следить за любыми ответами на эту запись через RSS 2.0 ленту. Вы можете оставить ответ, или trackback с вашего собственного сайта.

Оставьте отзыв

XHTML: Вы можете использовать следующие теги: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>

 
Rambler's Top100