ЛУЧШИЕ ПРАКТИКИ

Удобный для компилятора код: запечатанное ключевое слово в .NET C#

Почему и когда запечатанное ключевое слово может привести к повышению производительности в .NET C#

Что значит писать удобный для компилятора код?

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

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

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

Хватит болтать и давайте посмотрим на пример…



Проверка биографических данных

Если вы являетесь разработчиком .NET, даже новичком, вы уже должны знать, что в среде .NET есть ключевое слово, называемое sealed.

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

public sealed class MyClass {}

Или даже в объявлении метода, и тогда это означает, что метод больше не может быть переопределен каким-либо другим методом в дочернем классе. Другими словами, он прерывает серию переопределений методов на том уровне, где он используется. Это выглядит так:

public sealed override void MyMethod() {}

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

Сказав это, давайте теперь посмотрим, будет ли это что-то значить для компилятора.

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

Базовый класс

public class BaseClass
{
    public virtual void DoSomething()
    {
    }

    public void DoSomethingElse()
    {
    }
}

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

В этом классе определены следующие члены:

  • public virtual void DoSomething() метод.
  • public void DoSomethingElse() метод.

Мои занятия

public class MyClass : BaseClass
{
    public override void DoSomething()
    {
    }
}

Это класс, который наследуется от BaseClass, но без использования ключевого слова sealed в определении.

В этом классе мы переопределяем метод DoSomething, унаследованный от родительского класса BaseClass.

Мой СиледКласс

public sealed class MySealedClass : BaseClass
{
    public override void DoSomething()
    {
    }
}

Это класс, который наследуется от BaseClass, но на этот раз мы используем ключевое слово sealed в определении.

В этом классе мы переопределяем метод DoSomething, унаследованный от родительского класса BaseClass.

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

Вызов виртуального метода

Чтобы проверить, есть ли разница с точки зрения компилятора между вызовом виртуального метода для классов MyClass и MySealedClass, мы создали проект Benchmark.

[MemoryDiagnoser(false)]
public class Benchmarking
{
    private readonly int NumberOfTrials = 10;
    private MyClass _myClassObject = new MyClass();
    private MySealedClass _mySealedClassObject = new MySealedClass();

    [Benchmark]
    public void CallingVirtualMethodOnMyClass()
    {
        for (var i = 0; i < NumberOfTrials; i++)
        {
            _myClassObject.DoSomething();
        }
    }

    [Benchmark]
    public void CallingVirtualMethodOnMySealedClass()
    {
        for (var i = 0; i < NumberOfTrials; i++)
        {
            _mySealedClassObject.DoSomething();
        }
    }
}

Теперь, запустив проект Benchmark, мы получим следующий результат.

Как мы можем заметить, производительность вызова виртуального метода в запечатанном классе намного выше, чем при вызове его в незапечатанном классе.

Но почему?!!! Позвольте мне сказать вам.

На незапечатанном классе

При вызове виртуального метода для объекта, созданного из класса MyClass, в этот момент компилятор не знает, есть ли какой-то код, который повторно инициализирует объект _myClassObject новым экземпляром дочернего класса класса MyClass или нет. Это предположение верно, потому что класс MyClass не запечатан, а это значит, что он может быть унаследован.

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

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

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

На закрытом уроке

При вызове виртуального метода для объекта, созданного из класса MySealedClass, в этот момент компилятор не знает, есть ли какой-то код, который повторно инициализировал объект _mySealedClassObject с новым экземпляром или нет. Однако компилятор уверен, что даже если бы это произошло, экземпляр все равно был бы MySealedClass класса, поскольку он sealed, а это значит, что у него никогда не будет дочерних классов.

Исходя из этого, компилятор решит - во время компиляции - фактическую реализацию метода DoSomething. Это определенно намного быстрее, чем ожидание среды выполнения.

Вызов невиртуального метода

Чтобы проверить, есть ли разница с точки зрения компилятора между вызовом невиртуального метода для классов MyClass и MySealedClass, мы создали проект Benchmark. .

[MemoryDiagnoser(false)]
public class Benchmarking
{
    private readonly int NumberOfTrials = 10;
    private BaseClass _baseClassObject = new BaseClass();
    private MyClass _myClassObject = new MyClass();
    private MySealedClass _mySealedClassObject = new MySealedClass();
    private MyClass[] _myClassObjectsArray = new MyClass[1];
    private MySealedClass[] _mySealedClassObjectsArray = new MySealedClass[1];

    [Benchmark]
    public void CallingNonVirtualMethodOnMyClass()
    {
        for (var i = 0; i < NumberOfTrials; i++)
        {
            _myClassObject.DoSomethingElse();
        }
    }

    [Benchmark]
    public void CallingNonVirtualMethodOnMySealedClass()
    {
        for (var i = 0; i < NumberOfTrials; i++)
        {
            _mySealedClassObject.DoSomethingElse();
        }
    }
}

Теперь, запустив проект Benchmark, мы получим следующий результат.

Как мы можем заметить, производительность вызова невиртуального метода в запечатанном классе выше, чем при вызове его в незапечатанном классе.

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

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

Проверка типа

Чтобы проверить, будет ли какая-либо разница (с точки зрения компилятора) между проверкой типа объекта с помощью оператора is в классах MyClass и MySealedClass, мы создадим эталонные тесты проект.

[MemoryDiagnoser(false)]
public class Benchmarking
{
    private readonly int NumberOfTrials = 10;
    private BaseClass _baseClassObject = new BaseClass();
    
    [Benchmark]
    public bool ObjectTypeIsMyClass()
    {
        for (var i = 0; i < NumberOfTrials; i++)
        {
            var x = _baseClassObject is MyClass;
        }

        return true;
    }

    [Benchmark]
    public bool ObjectTypeIsMySealedClass()
    {
        for (var i = 0; i < NumberOfTrials; i++)
        {
            var x = _baseClassObject is MySealedClass;
        }

        return true;
    }
}

Теперь, запустив проект Benchmark, мы получим следующий результат.

Как мы можем заметить, производительность проверки типа объекта в запечатанном классе выше, чем при вызове его в незапечатанном классе.

Но почему?!!! Позвольте мне сказать вам.

На незапечатанном классе

При проверке того, является ли тип объекта классом MyClass, компилятору необходимо проверить, относится ли объект к типу класса MyClass или любому из его дочерних классов.

Таким образом, это приводит к большему количеству инструкций и, безусловно, большему количеству обработки и времени.

На закрытом уроке

При проверке того, является ли тип объекта MySealedClass классом, компилятору необходимо проверить, относится ли объект только к типу MySealedClass класса, и ничего больше. Это связано с тем, что класс MySealedClass запечатан, а это означает, что у него никогда не будет дочерних классов.

Таким образом, это приводит к меньшему количеству инструкций и, безусловно, меньшему количеству обработки и времени.

Тип литья

Чтобы проверить, будет ли какая-либо разница (с точки зрения компилятора) между приведением объекта с использованием оператора as к классам MyClass и MySealedClass, мы создадим тестовый тест. проект.

[MemoryDiagnoser(false)]
public class Benchmarking
{
    private readonly int NumberOfTrials = 10;
    private BaseClass _baseClassObject = new BaseClass();
    
    [Benchmark]
    public void ObjectTypeAsMyClass()
    {
        for (var i = 0; i < NumberOfTrials; i++)
        {
            var x = _baseClassObject as MyClass;
        }
    }

    [Benchmark]
    public void ObjectTypeAsMySealedClass()
    {
        for (var i = 0; i < NumberOfTrials; i++)
        {
            var x = _baseClassObject as MySealedClass;
        }
    }
}

Теперь, запустив проект Benchmark, мы получим следующий результат.

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

Но почему?!!! Позвольте мне сказать вам.

На незапечатанном классе

При преобразовании объекта как класса MyClass компилятору необходимо проверить, относится ли объект к типу класса MyClass или любому из его дочерних классов.

Таким образом, это приводит к большему количеству инструкций и, безусловно, большему количеству обработки и времени.

На закрытом уроке

При приведении объекта к классу MySealedClass компилятору необходимо проверить, относится ли объект только к классу MySealedClass и ничего больше. Это связано с тем, что класс MySealedClass запечатан, а это означает, что у него никогда не будет дочерних классов.

Таким образом, это приводит к меньшему количеству инструкций и, безусловно, меньшему количеству обработки и времени.

Хранение объекта в массиве

Чтобы проверить, есть ли разница с точки зрения компилятора между сохранением объекта в массиве в классах MyClass и MySealedClass, мы создали проект Benchmark. .

[MemoryDiagnoser(false)]
public class Benchmarking
{
    private readonly int NumberOfTrials = 10;
    private MyClass _myClassObject = new MyClass();
    private MySealedClass _mySealedClassObject = new MySealedClass();
    private MyClass[] _myClassObjectsArray = new MyClass[1];
    private MySealedClass[] _mySealedClassObjectsArray = new MySealedClass[1];

    [Benchmark]
    public void StoringValuesInMyClassArray()
    {
        for (var i = 0; i < NumberOfTrials; i++)
        {
            _myClassObjectsArray[0] = _myClassObject;
        }
    }

    [Benchmark]
    public void StoringValuesInMySealedClassArray()
    {
        for (var i = 0; i < NumberOfTrials; i++)
        {
            _mySealedClassObjectsArray[0] = _mySealedClassObject;
        }
    }
}

Теперь, запустив проект Benchmark, мы получим следующий результат.

Как мы можем заметить, производительность сохранения объекта в массиве в запечатанном классе выше, чем при вызове его в незапечатанном классе.

Но почему?!!! Позвольте мне сказать вам.

Прежде чем вдаваться в подробности, позвольте мне сначала напомнить вам о важном моменте; массивы являются ковариантными.

Это означает, что если у нас определены следующие классы:

public class A {}
public class B : A {}

Тогда следующий код будет действительным:

A[] arrayOfA = new B[5];

Кроме того, мы можем установить элемент внутри arrayOfA в экземпляр B следующим образом:

arrayOfA[0] = new B();

Сказав это, давайте теперь продолжим нашу основную тему.

На незапечатанном классе

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

Таким образом, это приводит к большему количеству инструкций и, безусловно, большему количеству обработки и времени.

На закрытом уроке

При установке элемента внутри массива _mySealedClassObjectsArray компилятору необходимо проверить, относится ли используемый нами экземпляр _mySealedClassObject только к классу типа MySealedClass и ни к чему другому. Это связано с тем, что класс MySealedClass запечатан, а это означает, что у него никогда не будет дочерних классов.

Таким образом, это приводит к меньшему количеству инструкций и, безусловно, меньшему количеству обработки и времени.

Раннее обнаружение отказа

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

Если мы напишем следующий код:

public void Run(MyClass obj)
{
    _ = _baseClassObject as IMyInterface;
}

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

Конечно, если во время выполнения окажется, что реальный тип obj не реализует IMyInterface, это вызовет исключение во время выполнения.

Однако, если мы напишем следующий код:

public void Run(MySealedClass obj)
{
    _ = _baseClassObject as IMyInterface;
}

Компилятор выдаст ошибку (CS0039) во время разработки, потому что obj может относиться только к классу MySealedClass, и никак иначе. Поэтому компилятор может мгновенно проверить, реализует ли класс MySealedClass IMyInterface или нет.

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

Последние мысли

Я всегда рекомендую использовать запечатанное ключевое слово, когда это применимо.

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

Наконец, я надеюсь, вам понравилось читать эту статью, как мне понравилось ее писать.

Надеюсь, вы нашли этот контент полезным. Если вы хотите поддержать:

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

Другие источники

Это другие ресурсы, которые могут оказаться полезными.