Pull to refresh
909.49
OTUS
Цифровые навыки от ведущих экспертов

По существу о C# 10.0: как нам упростили жизнь — Часть 1

Reading time10 min
Views14K
Original author: Mark Michaelis

Примечание: релиз десятой версии C# уже состоялся на момент выпуска перевода публикации, но обзор фичей будет полезен в любом случае.

Поскольку близится очередной релиз C#, что обычно происходит в ноябре каждого года, пришло время рассмотреть предстоящие улучшения для C# vNext: C# 10.0. Хотя среди них нет никаких новых крышесносных конструкций (нереально каждый год вводить что-то вроде LINQ), это ряд нужных улучшений, что вполне ожидаемо.

Резюмировать десятый релиз C# можно одной фразой — “избавление от ненужных церемоний”, таких как лишние фигурные скобки или повторяющийся код, которые не добавляют никакой ценности. Но такой синопсис не подразумевает, что сами эти изменения не имеют особой важности. Напротив, я думаю, что многие из этих изменений настолько сильны, что в будущем они станут нормой программирования на C#. Я подозреваю, что будущие разработчики C# не будут даже знать о старом синтаксисе. Другими словами, некоторые из этих улучшений настолько значительны, что вы, скорее всего, никогда не вернетесь к старым способам написания кода, если только вам не потребуется обратная совместимость или код в более ранней версии C#.

Объявление пространства имен на весь файл (#UseAlways)

Для начала рассмотрим очень простую фичу — объявление пространства имен на весь файл (file scoped namespace declaration). Раньше при объявлении пространства имен все содержимое этого пространства имен должно было помещаться в фигурные скобки. В C# 10.0 вы можете объявить пространство имен перед всеми другими объявлениями (классами, структурами и т. п.) и не ставить после него фигурные скобки. В результате пространство имен будет автоматически включать все определения, которые встречаются в файле.

В качестве примера рассмотрим следующий фрагмент кода:

namespace EssentialCSharp10;

static class HelloWorld
{
    static void Main() { }
    // ...
}

// Дополнительные объявления пространств имен в том же файле не разрешены
// namespace ScopedNamespaceDemo
// {
// }
// namespace AdditionalFileNamespace;

Здесь пространство имен CSharp10 объявлено самым первым до каких-либо других объявлений — таково требование для использования этого синтаксиса. Кроме того, объявление пространства имен на весь файл делает его эксклюзивным, т.е. в файле больше не допускаются никакие другие пространства имен: ни дополнительные пространства имен на весь файл, ни традиционные пространства имен с фигурными скобками.

Хоть это и незначительное изменение, я чувствую, что в будущем буду использовать эту фичу 10.0 практически везде. Без фигурных скобок конструкция не только проще, но и означает, что мне больше не нужно делать отступ для всех остальных объявлений в пространстве имен. По этой причине я пометил его тегом #UseAlways (буду использовать постоянно) в заголовке. Кроме того, я думаю, что эта фича заслуживает обновления гайдлайнов по написанию C#-кода: если у вас C# 10.0 или более поздняя версии — используйте объявления пространств имен на весь файл.

Директива Global Using (#UseAlways)

Моя рекомендация #UseAlways может показаться здесь неожиданной, поскольку объявления пространств имен менялись со времен C# 1.0, а C# 10.0 включает уже второе изменение, связанное с пространствами имен - глобальные директивы пространств имен.

Хорошие программисты и рефакторят хорошо! Почему же тогда C# вынуждает нас каждый раз объявлять ряд пространств имен в начале каждого файла? Например, абсолютное большинство файлов включают директиву using System вверху. Точно так же проект модульного тестирования практически всегда импортирует пространство имен для целевой тестируемой сборки и пространство имен тестовой среды. Так почему же нам необходимо снова и снова писать одну и ту же директиву using для каждого нового файла? Разве не лучше было бы иметь возможность написать одну директиву using, которая будет действовать глобально в рамках всего проекта?

Конечно, ответ на этот вопрос — “да”, однозначно. Для пространств имен, которые вы неустанно указывали после using в каждом файле, теперь есть возможность предоставить новую директиву global using, которая будет импортировать их рамках всего проекта. Синтаксис вводит новое контекстное ключевое слово global в качестве префикса стандартной директивы using, как показано в следующем фрагменте кода XUnit-проекта:

global using EssentialCSharp10;
global using System;
global using Xunit;

global using static System.Console;

Вы можете разместить приведенный выше фрагмент в любом месте вашего кода. Однако в соответствии с соглашением по написанию кода лучше это делать в GlobalUsings.cs или Usings.cs. После того, как вы единожды пропишете директивы global using, вы сможете пользоваться результатом во всех файлах вашего проекта:

public class SampleUnitTest
{

    [Fact]
    public void Test()
    {
        // Прописывать using System не нужно.
        DateTime dateTime = DateTime.Now;

        // Прописывать using Xunit не нужно.
        Assert.True(dateTime <= DateTime.Now);
        
        WriteLine("...");
    }
}

Обратите внимание, директивы using включают поддержку использования static, в результате чего у вас может быть оператор WriteLine() без квалификатора “System.Console”. Также поддерживаются глобальные псевдонимы, использующие синтаксис директив.

Помимо явного написания global using выражений в C#, вы также можете объявить их в MSBuild (начиная с версии 6.0.100-rc.1). Например Using элемент вашего CSPROJ-файла (т.е. <Using Include="Microsoft.VisualStudio.TestTools.UnitTesting" />) создает файл ImpklicitNamespaceImports.cs, который включает соответствующее глобальное объявление пространства имен. Кроме того, добавление атрибута static (например, Static="true") или атрибута alias, такого как Alias=" UnitTesting", создает соответствующие статические или псевдонимные директивы. Кроме того, некоторые фреймворки включают неявные глобальные директивы. Соответствующий список можно найти здесь. Однако, если вы предпочитаете, чтобы такие глобальные пространства имен не генерировались по умолчанию, вы можете отключить их с  помощью элемента ImplicitUsings, установленного в disabled или false. Ниже приведен пример PropertyGroup из файла CSPROJ:

<PropertyGroup>
    <ImplicitUsings>disable</ImplicitUsings>
    <Using>EssentialCSharp10</Using>
    <Using>System</Using>
    <Using>Xunit</Using>
    <Using Static="true">System.Console</Using>
</PropertyGroup>

Следует отметить, что это еще не работает в Visual Studio 2022 Preview 3.1.

Вам не нужно делать все свои директивы using глобальными, иначе вы с очень большой вероятностью будете натыкаться на двусмысленность в коротких форма написания имен типов. Тем не менее, я более чем уверен, что в каждом большом проекте будет присутствовать по крайней мере несколько глобальных объявлений (таких как объявления по умолчанию для какого-нибудь фреймворка), отсюда и тег #UseAlways.

Интерполированные константные строки (#UsedFrequently)

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

const string author = "Mother Theresa";
const string dontWorry = "Never worry about numbers.";
const string instead = "Help one person at a time and always " + 
    "start with the person nearest you.";
const string quote = $"{ dontWorry } { instead } - { author }";

Один из случаев, когда я особенно благодарен за константную интерполяцию, — это использование nameof внутри атрибутов, как показано в следующем фрагменте кода:

[Obsolete($"Use {nameof(Thing2)} instead.")]
class Thing1 { }
class Thing2 { }

До версии C# 10.0 невозможность использования оператора nameof внутри константного строкового литерала несомненно вызывала недоумение.

Улучшения лямбд

C# 10.0 включает три улучшения синтаксиса лямб — как самих выражений, так и инструкций.

Атрибуты

С появлением множества новых атрибутов параметров, представленных в C# 8 с nullable-ссылками, отсутствие поддержки атрибутов в лямбда-выражениях стало еще более заметным. К счастью, C# 10 наконец принес нам поддержку атрибутов лямбда-выражений, в том числе и атрибутов возвращаемого типа:

Func<string?, string[]?>? Func = [return: NotNullIfNotNull("cityState")]
    static (string? cityState) => cityState?.Split(", ");

Обратите внимание, что для того, чтобы в лямбда-выражении можно было использовать атрибуты, необходимо заключить список параметров в круглые скобки. _ = [return: NotNullIfNotNull("cityState")] cityState => cityState?.Split(", ") не допускается.

Явный возвращаемый тип

Если вы привыкли использовать объявление неявного типа с var, то не понаслышке знаете, что компилятор нередко не может определить сигнатуру метода. Рассмотрим, например, метод, возвращающий null, если ему не удается преобразовать текст в nullable int:

var func = (string? text) => int.TryParse(text, out number)?number:null;

Проблема здесь в том, что и int?, и object будут валидным возвратом. И не очевидно, что нужно использовать. Хотя результат можно преобразовать (cast), синтаксис для приведения большого выражения громоздок. Предпочтительная альтернатива, доступная в C# 10.0, состоит в том, чтобы разрешить объявление возвращаемого типа в рамках синтаксиса лямбд:

Func<string?, int?> func = int? (string? text) => 
    int.TryParse(text, out int number)?number:null;

Дополнительным бонусом для тех, кто не так часто использует var, является то, что добавление объявления типа возвращаемого значения позволяет выполнять быстрые действия по преобразованию var в явный лямбда-тип, как показано в приведенном выше фрагменте кода: Func<string?, int?>.

Обратите внимание, что лямбда-выражения, объявленные с синтаксисом delegate { }, не поддерживаются: Func<int> func = delegate int { return 42; } не скомпилируется.

Естественные типы функций

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

Атрибут вызывающей стороны (#UsedRarely)

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

Работа с методами атрибутов вызывающей стороны

Представьте себе функцию, которая валидирует строковый параметр, проверяя, не является ли он пустым или null. Вы можете использовать такую ​​функцию для свойства следующим образом:

using static EssentialCSharp10.Tests.Verify;
class Person
{
    public string Name
    {
        get => _Name ?? "";
        set => _Name = AssertNotNullOrEmpty(value);
    }
    private string? _Name;
}

В этом фрагменте кода нет какой-либо видимой фичи C# 10.0. Name — это не-nullable свойство, с assert-методом, который выбрасывает исключение, если значение равно null или пусто. Так в чем же заключается фича?

Разница проявляется во время выполнения. В этом случае, когда значение равно null или пусто, метод AssertNotNullOrEmpty() выбрасывает исключение ArgumentNull, сообщение которого включает выражение аргумента — “value”. И, если вызов метода был AssertNotNullOrEmpty("${firstName}{lastName}"), то ArgumentNull будет включать точный текст: "${firstName}{lastName}", потому что это было выражение аргумента, указанное при вызове метод.

Логирование — еще одна область, в которой эта функция может оказаться очень полезной. Вместо вызова Logger.LogExpression($"Math.Sqrt(number) == { Math.Sqrt(number) }" вы могли бы вызвать Logger.LogExpression(Math.Sqrt(number)) и предоставить методу Log() включить и значение, и выражение в выходном сообщении.

Реализация методов атрибутов вызывающей стороны

Одним из серьезных преимуществ атрибутов вызывающей стороны является расширенные возможности (о которых не обязательно беспокоиться) без внесения каких-либо изменений в исходный код. Но вам нужно понимать, как объявить и использовать эту фичу при реализации assert, logging или debug методов. Вот код, демонстрирующий AssertNotNullOrEmpty():

public static string AssertNotNullOrEmpty(
    string? argument,
    [CallerArgumentExpression("argument")]
        string argumentExpression = null!)
{
    if (string.IsNullOrEmpty(argument))
    {
        throw new ArgumentException(
            "Argument cannot be null or empty.",
            argumentExpression);
    }
    return argument;
}

Первое, на что следует обратить внимание, это атрибут CallerArgumentExpression, декорирующий параметр argumentsExpression. С добавлением этого атрибута, компилятор C# вставляет выражение, указанное в качестве аргумента, в argumentsExpression. Другими словами, хотя вызывающее выражение было написано как _Name = AssertNotNullOrEmpty(value), компилятор C# преобразует вызов в _Name = AssertNotNullOrEmpty(value, "value"). В результате метод AssertNotNullOrEmpty() теперь имеет вычисленное значение для выражения аргумента (в данном случае это значение “value” и само выражение). Таким образом, при возникновении ArgumentException сообщение может не только указать, что пошло не так, “Argument cannot be null or empty”, но и предоставить текст выражения “value”.

Обратите внимание, что CallerArgumentExpression включает строковый параметр, указывающий параметр, чье выражение будет CallerArgumentExpression в методе реализации. В данном случае, поскольку мы указали “argument”, в значение argumentsExpression будет подставляться выражение параметра “argument”.

В результате использование CallerArgumentExpression не ограничено всего до одного параметра. Вы могли бы, например, написать AssertAreEqual(expected,, [CallerArgumentExpression("expected")] string expectedExpression = null!, [CallerArgumentExpression("actual") ] string actualExpression = null!) и в результате генерировать исключения, которые показывают и сами выражения, а не только конечные результаты.

При реализации CallerArgumentExpression нужно учитывать следующие рекомендации:

  • Объявляйте параметр CallerArgumentExpression опциональным (используя "=null!"), чтобы вызов метода не требовал от вызывающей стороны явного определения выражения. Кроме того, это позволяет добавлять фичу в существующие API без изменения кода вызывающей стороны.

  • Рассмотрите возможность объявления параметра CallerArgumentExpression как не-nullable и присвоение значение null с помощью null-forgiving оператора (!). Это позволяет компилятору указать значение по умолчанию и подразумевает, что оно не должно быть null, если задано явное значение.

Как это ни печально, но в скобках, начиная с C# 10.0, вы не можете использовать nameof для идентификации параметра. Например, CallerArgumentExpression(nameof(argument)) не будет работать. Это связано с тем, что параметр аргумента не находится в области действия во время объявления атрибута. Однако такая поддержка находится на рассмотрении после C# 10.0 (см. Поддержка имен параметров метода в nameof(): https://github.com/dotnet/csharplang/issues/373).


Приглашаем всех желающих на открытое занятие «Дженерики, их реализация и ограничения». На занятии рассмотрим обобщенные типы и методы, причины появления, их использование; обсудим ограничения обобщений и варианты наследования обобщенных типов. Регистрация по ссылке.

Tags:
Hubs:
Total votes 20: ↑18 and ↓2+16
Comments7

Articles

Information

Website
otus.ru
Registered
Founded
Employees
101–200 employees
Location
Россия
Representative
OTUS