Как объявить свойства расширения в C# 14
Свойства расширения появляются в C# 14 через новый блок extension. Объявляйте свойства расширения только для чтения, с сеттером, статические и обобщённые, почему автосвойства отвергаются и как компилятор преобразует их в аксессоры get_/set_.
Короткий ответ: объявите свойство расширения внутри блока extension в статическом классе. Назовите получатель, чтобы добавить свойство экземпляра (extension(string s) { public int WordCount => ...; }), опустите имя, чтобы добавить статическое (extension(Point) { public static Point Origin => ...; }). Тело свойства — это геттер; добавьте аксессор set для записываемого свойства. Единственное правило, на котором спотыкаются все: полей расширения не существует, поэтому автосвойство вроде public int Count { get; set; } не скомпилируется. Каждый аксессор должен вычислять значение или перенаправлять его в реальное хранилище.
Эта возможность появляется в C# 14, который требует SDK .NET 10 или новее (она работает так же под SDK .NET 11). Установите <LangVersion>14</LangVersion> или <LangVersion>latest</LangVersion> в вашем .csproj. Свойства расширения — одна часть более широкой возможности членов расширения; эта статья является сфокусированным руководством по половине, посвящённой свойствам. Если вы хотите более широкий обзор, который также охватывает операторы и статические члены, прочитайте обзор членов расширения C# 14.
Почему вы никогда не могли написать string.WordCount до C# 14
Методы расширения существуют со времён C# 3.0, но они расширяли только один вид членов: методы. Если вы хотели добавить вычисляемое значение к типу, которым не владеете, вам приходилось писать его как вызов метода:
// Before C# 14 - the only option was a method
public static class StringExtensions
{
public static int WordCount(this string s) =>
s.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length;
}
// Call site reads like a function, not a property
int n = "hello there world".WordCount();
Эти завершающие () — характерный признак. WordCount концептуально является свойством строки, но язык вынуждал его принимать форму метода. Автосвойства, вычисляемые свойства и индексаторы для типов, которыми вы не управляете, были просто недостижимы. C# 14 закрывает этот пробел блоком extension — контейнером, который может содержать свойства, операторы и статические члены наряду со старыми методами в стиле this.
Объявите свойство расширения за три шага
- Создайте необобщённый статический класс верхнего уровня для размещения расширения. Это то же правило вложенности, что и у классических методов расширения: класс не может быть вложенным и не может быть обобщённым.
- Откройте блок
extensionи объявите получатель. Напишитеextension(string s), чтобы назвать экземпляр, который расширяет свойство, илиextension(string)без имени для статического свойства самого типа. - Объявите свойство внутри блока с геттером в виде выражения (или полным телом
get/set). Ссылайтесь на параметр-получатель по имени, которое вы дали ему на шаге 2.
Собрав всё вместе, пример WordCount становится настоящим свойством:
// .NET 11, C# 14 - an instance extension property
public static class StringExtensions
{
extension(string s)
{
public bool IsBlank => string.IsNullOrWhiteSpace(s);
public int WordCount =>
s.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length;
}
}
Теперь место вызова теряет скобки и читается в точности как встроенный член:
string title = "hello there world";
Console.WriteLine(title.WordCount); // 3
Console.WriteLine(title.IsBlank); // False
Имя получателя (здесь s) находится в области видимости для каждого члена внутри блока, поэтому связанные свойства разделяют одно объявление того, что они расширяют. В этом и есть весь смысл блока: он группирует члены по типу, который они дополняют, вместо повторения this string в каждой сигнатуре.
Записываемым свойствам расширения нужно куда поместить значение
Свойства расширения по умолчанию не только для чтения. Вы можете добавить аксессор set, но поскольку среда выполнения не даёт расширению никакого места для хранения данных, сеттер должен перенаправлять значение в хранилище, которое уже существует на получателе. Чистый случай — предоставить альтернативное представление поля, которым тип уже владеет:
// .NET 11, C# 14 - a get/set extension property over existing state
public class Sensor
{
public double Celsius { get; set; }
}
public static class SensorExtensions
{
extension(Sensor sensor)
{
public double Fahrenheit
{
get => sensor.Celsius * 9 / 5 + 32;
set => sensor.Celsius = (value - 32) * 5 / 9;
}
}
}
Сеттер читает value как любой сеттер свойства и записывает через реальное поле Celsius:
var s = new Sensor { Celsius = 20 };
Console.WriteLine(s.Fahrenheit); // 68
s.Fahrenheit = 212;
Console.WriteLine(s.Celsius); // 100
Чего вы не можете сделать, так это попросить компилятор изобрести хранилище за вас. Это самая распространённая ошибка компиляции, с которой сталкиваются люди:
public static class SensorExtensions
{
extension(Sensor sensor)
{
// ERROR: an extension property cannot be an auto-property,
// because there is no backing field to generate.
public string Label { get; set; }
}
}
В C# 14 нет полей расширения, поэтому нет резервного поля для синтеза. Каждый аксессор должен иметь тело, которое вычисляет значение или направляет его через члены, которыми получатель уже владеет. Если вам действительно нужно прикрепить новое состояние к экземплярам типа, которым вы не управляете, свойство расширения — неподходящий инструмент; обратитесь к ConditionalWeakTable<TKey, TValue> с экземпляром в качестве ключа и предоставьте его через геттер и сеттер.
Изменение структуры требует получателя ref
Пример с Sensor работает, потому что Sensor — это класс, поэтому сеттер изменяет объект, который разделяют все. Для типа значения получатель по умолчанию копируется, и сеттер изменил бы эту одноразовую копию. Объявите получатель как ref, чтобы записать обратно в оригинал, точно так же, как this ref работал для изменяющих методов расширения:
// .NET 11, C# 14 - ref receiver so the setter mutates the caller's struct
public static class PointExtensions
{
extension(ref System.Drawing.Point p)
{
public int ManhattanLength
{
get => Math.Abs(p.X) + Math.Abs(p.Y);
}
}
}
Получатель ref также означает, что свойство можно использовать только с адресуемой переменной, а не с временным значением вроде результата вызова метода. Это ограничение — то же самое, которое всегда несли методы расширения с ref, и именно оно делает изменение безопасным.
Статические свойства расширения опускают имя получателя
Опустите имя параметра, и блок расширяет сам тип, а не экземпляр. Так вы добавляете именованные константы или значения в стиле фабрики, которые читаются как статические члены типа, которым вы не владеете:
// .NET 11, C# 14 - a static extension property on a type you don't own
using System.Drawing;
public static class PointExtensions
{
extension(Point)
{
public static Point Origin => Point.Empty;
}
}
Место вызова выглядит как статический член, который был там всегда:
Point start = Point.Origin;
Статические члены и члены экземпляра могут жить в отдельных блоках внутри одного класса. Используйте блок с именованным получателем для членов экземпляра и блок с голым типом для статических; компилятор рад обоим стилям рядом в одном статическом классе.
Обобщённые свойства расширения: каждый параметр типа должен достигать получателя
Поместите параметры типа на ключевое слово extension, и они переходят к каждому члену внутри блока. Это позволяет добавлять свойства к открытым обобщённым типам вроде IReadOnlyList<T>:
// .NET 11, C# 14 - generic extension properties
public static class ListExtensions
{
extension<T>(IReadOnlyList<T> list)
{
public bool IsEmpty => list.Count == 0;
public T? LastOrDefaultValue =>
list.Count > 0 ? list[^1] : default;
}
}
Есть одно жёсткое ограничение, которое применяет компилятор: каждый параметр типа, объявленный в блоке, должен использоваться типом получателя. extension<T>(IReadOnlyList<T> list) допустим, потому что T появляется в IReadOnlyList<T>. Блок вроде extension<T>(string s), который объявляет T, но никогда не использует его в получателе, является ошибкой компиляции, потому что компилятору не из чего вывести T в месте вызова. Ограничения также идут на блок:
public static class ComparableExtensions
{
extension<T>(IReadOnlyList<T> list) where T : IComparable<T>
{
public T Max
{
get
{
var max = list[0];
for (int i = 1; i < list.Count; i++)
if (list[i].CompareTo(max) > 0) max = list[i];
return max;
}
}
}
}
Как компилятор преобразует свойство расширения и как устранить неоднозначность
Свойство расширения — это чистый синтаксический сахар времени компиляции. Компилятор превращает блок в обычные статические методы-аксессоры во вложенном скрытом типе: геттер с именем get_PropertyName и, если он есть, сеттер с именем set_PropertyName, каждый из которых принимает получатель в качестве первого аргумента. Когда вы пишете title.WordCount, компилятор переписывает это в вызов сгенерированного аксессора get_WordCount. Порядок параметров типа в преобразованной форме — сначала параметры получателя, затем любые параметры метода, что важно, только если вы изучаете сгенерированные метаданные.
Из этого следуют два следствия. Во-первых, разрешение использует те же правила области видимости, что и методы расширения: побеждает кандидат в ближайшем охватывающем пространстве имён или using, и когда два свойства расширения с одним именем одинаково находятся в области видимости, вы получаете ошибку неоднозначности, а не тихий выбор. Вы решаете её, сужая директивы using или квалифицируя через статический класс, чтобы компилятор знал, какой аксессор вы имеете в виду. Во-вторых, поскольку свойство существует только в месте вызова, оно никогда не появляется в рефлексии времени выполнения над расширенным типом: typeof(string).GetProperty("WordCount") возвращает null. Свойства расширения — это удобство языка, а не изменение типа во время выполнения, поэтому всё, что рефлексирует над реальными членами (сериализаторы, привязка данных, ORM), их не увидит.
Допустимость null вы объявляете сами
Поскольку вы пишете параметр-получатель сами, вы решаете, принимает ли свойство null-получатель. Аннотируйте получатель как допускающий null, чтобы написать свойство, которое безопасно вызывать на null-ссылке, чего обычное свойство экземпляра никогда не может:
// .NET 11, C# 14 - a null-tolerant extension property
public static class StringExtensions
{
extension(string? s)
{
public bool HasText => !string.IsNullOrWhiteSpace(s);
}
}
string? maybe = null;
Console.WriteLine(maybe.HasText); // False, no NullReferenceException
Это хорошо сочетается с обработкой null в месте вызова, которая появилась вместе с этим; смотрите условное присваивание null в C# 14 для улучшений ?. и ?[] в левой части присваивания.
Граничные случаи, которые стоит знать перед выпуском
Несколько правил и ограничений избавят вас от запутанной ошибки компиляции:
- Никаких полей, никаких автосвойств, никаких событий, никаких конструкторов. Блоки
extensionв C# 14 поддерживают методы, свойства, индексаторы и операторы. Поля явно исключены, что как раз и является причиной, почему автосвойства отвергаются. - Контейнер должен быть необобщённым, невложенным статическим классом. Помещайте параметры типа в блок
extension, а не в класс. - Коллизия имени с реальным членом проигрывает. Если у расширенного типа уже есть свойство
WordCount, реальное всегда побеждает, и ваше свойство расширения никогда не рассматривается. Расширения только заполняют пробелы; они никогда не переопределяют. - Индексаторы следуют той же форме. Вы можете объявить
public T this[int i] => ...внутри блокаextensionэкземпляра, что даёт вам индексаторы расширения для типов, у которых их нет.
Свойства расширения — самая эргономичная часть работы над членами расширения, и они чисто компонуются с остальной частью C# 14. Если вы добавляете вычисляемые члены к типу, которым управляете, в сравнении с тем, которым нет, взвесьте их относительно других инструментов формирования из этого выпуска: и приём с членами расширения для возврата нескольких значений, и определяемые пользователем операторы составного присваивания опираются на то же семейство синтаксиса.
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.