Методы

Конструкторы экземпляров и классы (ссылочные типы)

Конструкторы - специальные методы, позволяющие корректно инициализировать новый экземпляр типа. В таблице определений в метаданных отмечаются сочетанием .ctor (от constructor). При создании экземпляра ссылочного типа выделяется память для полей данных и инициализируются служебные поля (индекс блока синхронизации и ссылка на объект-тип), после чего вызывается конструктор экземпляра, устанавливающий исходное состояние нового объекта.

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

В отличие от других методов конструкторы экземпляров не наследуются: у класса есть только те конструкторы, которые определены в классе. Невозможность наследования означает, что к конструкторам невозможно применить модификаторы наследованияopen in new window. Если определить класс без явно заданных конструкторов, компилятор создаст конструктор по умолчанию, который просто вызывает конструктор без параметров базового класса.

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

В типе можно определить несколько конструкторов, при этом сигнатуры и уровни доступа должны отличаться. В случае верифицируемого кода конструктор экземпляров должен вызвать конструктор базового класса до обращения к какому-либо из унаследованных от него полей. В конечном счёте всегда вызывается конструктор System.Object, который ничего не делает, а только возвращает управление, так как в object не определены поля.

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

Нельзя вызывать какие-либо виртуальные методы конструктора, которые могут повлиять на создаваемый объект. Потому что если виртуальный метод переопределён в производном типе, то реализация производного типа вызовется до того, как завершится инициализация всех полей в иерархии. На примере: конструктор базового типа вызывает виртуальный метод, виртуальный метод переопределён в наследнике, наследник вызывает конструктор, вызывается конструктор базового типа - получилась петля. Но если виртуальный метод не переопределён, тогда всё нормально. В таких обстоятельствах последствия вызова непредсказуемы.

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

Конструкторы экземпляров и структуры (значимые типы)

Конструкторы значимых типов работают иначе. CLR всегда разрешает создание экземпляров значимых типов и этому ничто не может помешать. Поэтому по большому счёту конструкторы у значимого типа можно не определять. Фактически компиляторы не определяют для значимых типов конструкторы по умолчанию.

internal struct Point
{
  public Int32 m_x, m_y;
}

internal sealed class Rectangle
{
  public Point m_topLeft, m_bottomRight;
}

Для создания объекта ссылочного типа надо использовать оператор new() с указанием конструктора. В этом случае вызывается конструктор по умолчанию. Память, выделенная для объекта ссылочного типа включает место для двух экземпляров значимого типа. Из соображений производительности CLR не пытается вызвать конструктор для каждого экземпляра значимого типа, содержащегося в объекте ссылочного типа. Поля значимого типа инициализируются нулями/null.

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

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

Если значимый тип уже определён, то определяется конструктор, по умолчанию не имеющий параметров.

Фактически большинство компиляторов никогда не генерирует автоматически код для вызова конструктора по умолчанию для значимого типа даже при наличии конструктора без параметров. Для исполнения конструктора значимого типа без параметров, разработчик должен явно его вызвать. Однако C# не позволяет даже создать конструктор без параметров, и выдаст ошибку компиляции. Это сделано специально, чтобы не вводить разработчиков в заблуждение.

В поля значимого типа обязательно заносятся значения 0 или null, если значимый тип вложен в объект ссылочного типа. Однако сказать такое про значимый тип, хранящийся в стеке, нельзя. Так как код верифицируемый, компилятор гарантирует, что поля значимого типа обнулятся или заполнятся каким-то значением, так что исключений возникнуть не должно.

C# не допускает конструктором без параметров для значимых типов, но CLR это разрешает, так что такую структуру можно написать на IL. По этой же причине нельзя использовать инициализаторы полей (они разворачиваются в конструктор без параметров). Конструктор без параметров выглядит так, будто он будет вызван неявно, но этого не произойдёт.

При наличии конструктора с параметрами, он должен инициализировать все поля. Это можно обойти, если прописать в конструкторе this = new SomeValueType();, а затем инициализировать необходимые поля. В ссылочных типах указатель this может быть использован только для чтения.

Конструкторы типов

Помимо экземплярных конструкторов, CLR также поддерживает конструкторы типов (статические конструкторы / конструкторы классов / инициализаторы типов). Конструкторы типов можно применять в интерфейсах (не в C#), ссылочным и значимым типам. Данные конструкторы определяют первоначальное состояние объекта-типа. По умолчанию у типа не определено конструктора. У типа не может быть более одного конструктора. У конструктора типа не должно быть параметров.

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

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

При компиляции метода JIT-компилятор обнаруживает типы, на которые есть ссылки из кода. Если в каком-то типе есть конструктор, то JIT-компилятор проверяет, был ли он исполнен в текущем домене.

Затем после JIT-компиляции метода начинается выполнение потока. В реальности потоков может быть несколько. В этом случае CLR старается гарантировать, чтобы конструктор типа выполнялся единожды в каждом домене. Для этого при вызове конструктора типа вызывающий поток получает исключающую блокировку. Это означает, что при попытке вызвать конструктор типа только один поток получит эту возможность, остальные будут заблокированы. Первый поток выполнит код статического конструктора, после чего проснутся остальные потоки и проверят, был ли вызван данный конструктор. Потоки не станут вызывать конструктор, а просто вернут управление.

Благодаря этой особенности конструктор типа лучше всего подходит для инициализации объектов-одиночек.

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

Наконец, если конструктор типа выбрасывает исключение, тип считается непригодным и при попытке обращения к любому полю этого типа возникает исключение System.TypeInitializationException.

Статический конструктор может инициализировать только статические поля. C# предлагает простой синтаксис через инициализатор.

C# не разрешает использовать синтаксис инициализации полей в значимых типах. Однако в значимых типах по-прежнему можно использовать инициализацию статических полей.

В таблице определений типов метод-конструктор типа называется .cctor (от class constructor).

При наличии в классе инициализации статического поля и статического конструктора код инициализации как бы вставляется в конструктор перед всеми его операциями.

Несмотря на то, что для типов не существует статических методов Finalize(), выгрузить тип из домена можно, зарегистрировав колбэк метод для события DomainUnload типа System.AppDomain. Хотя это и не имеет особого смысла, так как GC освобождает всю занятую память при закрытии домена.

Методы перегруженных операторов

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

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

Спецификация CLR требует, чтобы перегруженные операторы были статическими и открытыми. Кроме этого сам C# требует, чтобы тип одного из параметров совпадал с типом, в котором определена перегрузка операторов. Это помогает компилятору в разумное время находить кандидатуры операторных методов для привязки.

В книге можно найти примеры доступных для перегрузки операторов (см. стр. 227-228).

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

Операторы и взаимодействие языков программирования

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

Особое мнение автора о правилах Microsoft, связанных с именами методов операторов

Автор считает, что Microsoft излишне усложнила функционал перегрузки операторов путём добавления флага specialname, так как все языки могли бы переопределять операторы, а разработчикам было бы проще использовать код, написанный на других языках. Microsoft же предлагает рядом с перегруженными операторами определять методы с дружественными именами, вызывающие методы перегруженных операторов. Однако это значительно усложняет написание и замедляет быстродействие. Примером такого типа служит System.Decimal.

Методы операторов преобразования

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

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

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

Что происходит под капотом? Компилятор обнаруживает в исходном тексте операции приведения и при помощи внутренних механизмов генерирует IL-код, который вызывает методы приведения, определённые в исходном типе.

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

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

Методы расширения

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

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

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

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

Правила и рекомендации

Несколько правил и фактов о методах расширения:

  • C# поддерживает только методы расширения (нет свойств расширения, событий расширения и т. д.).
  • Методы расширения должны быть объявлены в статическом необобщённом классе. Метод расширения должен иметь как минимум один параметр и только первый параметр должен быть отмечен ключевым словом this.
  • Метод расширения должен быть определён в статических классах первого уровня (в области файла, не типа).
  • C# просматривает все статические классы на предмет метода расширения. Для ускорения этого необходимо в начале файла использовать директиву using с указанием пространства имён, где определён данный метод.
  • Если в нескольких статических классах определены методы расширения с одинаковыми именами, тогда стоит вызывать метод расширения с явным указанием имени класса.
  • При написании методов расширения не стоит увлекаться: писать их следует только над теми типами, которым это необходимо (например, при указании первым параметром типа System.Object метод можно будет применить к любому объекту, что только загромоздит подсказки IDE).
  • Следует использовать методы расширения аккуратно, так как если расширенный тип в будущем обретёт одноимённый экземплярный метод, будет вызываться именно он, что может нарушить работу приложения.

Расширение разных типов методами расширения

Так как метод расширения является вызовом статического метода, к нему не применяется проверка на null.

Методы расширения можно также применять для интерфейсных типов.

Хорошим примером методов расширения является статический класс System.Linq.Enumerable.

Методы расширения можно определять для типов-делегатов.

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

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

Атрибут расширения

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

Частичные методы

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

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

Для решения этой проблемы можно использовать механизм частичных методов. Объявляющий тип и метод помечаются ключевым словом partial.

В этом случае есть ряд особенностей:

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

У частичных методов имеется одно серьёзное преимущество. Если реализации частичного метода не будет, то компилятор не сгенерирует для него метаданные, команды вызова и код, вычисляющий аргументы для передачи частичному методу. В результате будет меньше метаданных, IL-кода и производительность повысится.

Правила и рекомендации

Несколько дополнительных рекомендаций касаемо частичных методов:

  • Частичные методы могут объявляться только внутри частичного класса или структуры.
  • Частичные методы всегда должны иметь возвращаемый тип void и не иметь параметров, помеченных ключевым словом out. Это необходимо потому, во время выполнения программы метода не существует и вы не можете инициализировать переменную, возвращаемую этим методом. Однако в частичном методе можно использовать ключевое слово ref, универсальные параметры, экземплярные или статически, а также параметры, помеченные как unsafe.
  • Сигнатуры частичных методы должны быть одинаковыми, а атрибуты - объединяемыми.
  • Если не существует имплементации частичного метода, то нельзя создавать делегат, ссылающийся на него.
  • Частичные методы считаются закрытыми, но компилятор запрещает писать ключевое слово private.