вторник, 12 апреля 2011 г.

Про sealed-классы

В C# есть ключевое слово «sealed». Применяется оно в форме модификатора к классам, методам, свойствам и событиям, но сегодня речь пойдёт только про классы.

Что все знают про sealed-классы

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

Нужны ли вообще sealed-классы?

На эту тему изломано немало копий. Как и в любом споре здесь есть две стороны. Первая сторона считает sealed-классы ненужными (и даже вредными). Основные аргументы такие:
  • В других языках прекрасно обходятся без sealed-классов, поэтому sealed-классы и в C# не нужны.
  • От sealed-класса невозможно отнаследоваться в случае надобности.
  • Модификатор sealed нужно специально прописывать, это лишняя работа с сомнительной пользой.
Вторая сторона (к которой принадлежу и я) считает sealed-классы полезным механизмом. По своей сути, sealed-классы — это способ ввести ограничение на наследование. Как и другие механизмы ограничений (например, модификаторы доступа к членам класса, статическая типизация, ограничения (constraints) в РСУБД), sealed-классы позволяют:
  • Выполнять оптимизации.
  • Выявлять ошибки и несогласованности.
 

Возможности для оптимизации

Sealed-классы дают не так уж много возможностей для оптимизации, я знаю только про две возможности. Первая — это оптимизация обращений к виртуальным членам класса (методам, свойствам, событиям). Знание того факта, что у класса нет наследников, позволяет вызывать его виртуальные члены как невиртуальные, а это несколько быстрее[1]. Подчеркну, что это всего лишь возможность. Никаких гарантий выполнения этой оптимизации никто не даёт. Второй вид оптимизаций — поиск атрибутов, являющихся sealed-классами, выполняется быстрее[2].  

Выявление ошибок и несогласованностей

Главный плюс sealed-классов для меня заключается в том, что они вносят в код новую семантику, которая предоставляет дополнительную информацию инструментам (компилятору, intellisense, ReSharper и т.п.). Это, в свою очередь, позволяет выявлять существующие ошибки и избыточный код, предотвращать появление новых ошибок.  

Модификаторы sealed, abstract и static

По сути, модификаторы sealed, abstract и static входят в одну группу. Они определяют, как можно использовать класс:
  • Если у класса нет модификаторов sealed, abstract и static, класс может использоваться как для создания экземпляров класса (инстанцирование), так и в качестве базового класса.
  • Если у класса есть модификатор abstract, то он может использоваться только в качестве базового.
  • Если у класса есть модификатор sealed, он может использоваться только для создания экземпляров.
  • Модификатор static запрещает создавать экземпляры класса и использовать класс в качестве базового. Фактически, модификатор static — это сокращение для abstract sealed.
Можно ли вообще обойтись без этих модификаторов? Теоретически можно. Представим язык, аналогичный языку C#, но без вышеперечисленных модификаторов для классов. Можно ввести соглашение, которое предусматривает следующее:
  • Если нужен статический класс — создаём обычный класс, в котором будут только статические члены, никогда не создаём экземпляров этого класса и никогда от него не наследуемся.
  • Если нужен sealed-класс — создаём обычный класс и никогда от него не наследуемся.
  • Если нужен абстрактный класс — создаём обычный класс и никогда не создаём его экземпляров.
Будет ли это работать? Конечно, будет. Но только в мире, где все программисты никогда не ошибаются. И что-то мне подсказывает, что это не наш мир. Я даже не говорю про случай, когда какой-то программист решит нарушить это соглашение специально (в конце концов, если у него есть доступ к коду, он может сделать вообще всё что угодно, в независимости от языка). Я говорю про ситуацию, когда соглашение нарушается ненамеренно, нечаянно. Все программисты — в первую очередь люди, а людям свойственно ошибаться. C# позволяет взвалить все эти проверки по соблюдению вышеперечисленных правил на компилятор, который не ошибается. И проверки эти будут выполняться каждый раз при компиляции. Да, для этого нужно указать у класса нужный модификатор. Но:
  • Это делается только один раз в одном месте (в том числе и для partial-классов), единообразным способом.
  • В случае соглашений всё равно придётся искать замену модификаторам и как-то и где-то указывать на нужное соглашение (например, в комментарии: «Этот класс статический, не создавайте его экземпляров»).

Какие ошибки помогают выявить sealed-классы?

То, что компилятор не будет компилировать код, в котором есть наследование от sealed-класса, понятно, в этом и есть суть sealed-классов. Но какие ещё ошибки можно выявить, если использовать модификатор sealed? Рассмотрим небольшой пример:
using System.Linq;

namespace Sample
{
    public class Foo {}

    public class GenericClass<T> where T : Foo {}

    public interface IBar {}

    internal static class Program
    {
        private static void SomeMethod(Foo foo, IBar bar)
        {
            var x1 = (IBar)foo;
            var x2 = (Foo)bar;

            var x3 = foo as IBar;
            var x4 = bar as Foo;

            if (foo is IBar) {}
            if (bar is Foo) {}

            if (foo == bar) {}
            if (foo != bar) {}

            foreach (Foo x in Enumerable.Empty<IBar>())
            {}
            foreach (IBar x in Enumerable.Empty<Foo>())
            {}

            var x5 = (IBar[])new Foo[0];
            var x6 = (Foo[])new IBar[0];
        }

        private static void Main() {}
    }
}
Этот код успешно компилируется, без ошибок и предупреждений. Теперь давайте введём условие: «Класс Foo не имеет наследников» и проанализируем код с учётом этого условия:
  1. public class GenericClass<T> where T : Foo {} Здесь generic-параметр T фактически может быть только одного типа — Foo, т.к. у Foo нет наследников. Это значит, что generic-параметр в этом классе вообще не нужен, вместо T везде можно подставить Foo.
  2. Оба приведения типов будут в процессе выполнения гарантировано приводить к генерации исключения InvalidCastException, т.к. ни класс Foo, ни его наследники (которых просто нет) не реализуют интерфейс IBar.
  3. Оба оператора as всегда будут возвращать только null (см. п. 2).
  4. Оба оператора is всегда будут возвращать false (см. п. 2).
  5. Оператор равенства всегда будет возвращать false, оператор неравенства — true (если только оба сравниваемых значения не равны null) (см. п. 2).
  6. При выполнении циклов будет генерироваться исключение из-за приведения типов, которое сгенерирует компилятор (см. п. 2).
  7. При приведении типов массивов так же будет сгенерировано исключение (см. п. 2).
Все эти проблемы компилятор способен выявить. Для этого ему нужно всего лишь подсказать, что у класса Foo нет наследников:
public sealed class Foo {}
После этого шесть пунктов из перечисленных семи будут приводить к ошибкам времени компиляции. Пункт 4 будет приводить к предупреждениям (warning) компилятора. Что же происходит при добавлении модификатора sealed? Для этого стоит обратиться к спецификации языка (я буду ссылаться на спецификацию C# 4.0):
  • В разделе «10.1.5 Type parameter constraints» говорится о том, что sealed-класс не может использоваться в качестве ограничения на тип generic-параметра.
  • В разделе «6.2.4 Explicit reference conversions» перечисляются требования к типам, участвующим в приведении. Там говорится, что при приведении от типа класса к типу интерфейсу тип класса не должен реализовывать интерфейс, от которого идёт приведение и не быть sealed-классом; при приведении от типа интерфейса к типу класса тип класса должен или реализовывать интерфейс, к которому происходит приведение, или не быть sealed-классом.
Все ошибки компиляции, появившиеся после добавления классу Foo модификатора sealed, прямо или косвенно обусловлены этими двумя пунктами спецификации языка. Вряд ли такой код, как в методе SomeMethod, может быть написан специально. Но он вполне может появиться в проекте ненамеренно, в результате изменений кода в другой части проекта или банальной опечатки (вполне реальный случай из моей практики). В конце концов, раз уж язык предоставляет такую простую, понятную и безопасную возможность, которая позволяет отловить ряд ошибок на этапе компиляции, почему бы ей не воспользоваться?  

Когда использовать модификатор sealed?

Ответ на этот вопрос зависит от того, какой код пишется. Если это какой-то фреймворк или библиотека или внешний API для сторонних разработчиков, очень трудно заранее предугадать, как будут использоваться публичные классы. Применение атрибута sealed ко всем классам без разбора может помешать пользователям вашей библиотеки использовать её так, как они считают нужным. Однако следует помнить, что всегда можно убрать модификатор sealed в новой версии библиотеки, а вот добавить его, из-за риска огрести проблемы с совместимостью, можно не всегда. Кроме того, во многих случаях есть другие альтернативы наследованию:
  • Наследник нужен для добавления дополнительных методов/свойств — можно использовать extension-методы.
  • Наследник нужен для упрощения конструирования объекта, чтобы передавать в конструктор базового класса предопределённые значения параметров — можно использовать фабричные методы.
  • Наследник нужен для реализации интерфейса — можно использовать делегирование, создать класс-обёртку, который реализует интерфейс и перенаправляет вызовы объекту, выполняющему работу.
  • Наследник нужен чтобы каждый раз не задавать тип generic-параметра (class ListOfStrings : List<string> {}) — можно создать псевдоним с помощью директивы using (using ListOfStrings = System.Collections.Generic.List<string>;).
В остальных же случаях я рекомендую помечать все классы атрибутом sealed (естественно кроме тех, которые являются базовыми для других классов). Хуже от этого точно не будет, а в случае надобности атрибут можно убрать. Я бы даже предпочёл, чтобы классы в C# по умолчанию были sealed, без необходимости указывать атрибут, а для базовых неабстрактых классов был бы специальный атрибут (например, nonsealed). Кстати, того же мнения придерживаются такие авторитетные товарищи как Джефри Рихтер[3] и Барт Де Смет[4].

[1] Спецификация C# 4.0 (раздел «10.1.1.2 Sealed classes»).
[2] «Framework Design Guidelines», second edition (глава 8, раздел «8.2 Attributes»).
[3] «CLR via C#», third edition (глава 6, раздел «Using Type Visibility and Member Accessibility Intelligently»).
[4] «C# 4.0 Unleashed» (глава 14, раздел «Inheritance»).

4 комментария:

  1. Отличное, всестороннее описание.

    Теперь есть одна ссылка которую можно дать на ревью :).

    ОтветитьУдалить
  2. А я то думал зачем может sealed понадобится, кроме как для повышения быстродействия. Спасибо, теперь буду знать.

    ОтветитьУдалить
  3. Спасибо, оч познавательная статья
    мне нравится как обстоятельно вы все по пунктам расписываете.

    ОтветитьУдалить