[Перевод] Запланированные новые возможности C# 8.0

ta4hd5nyoa--cjkunjyjjqhpam0.jpeg

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

Этот подход был преднамеренным, и он остается в силе.

Более серьезные изменения, которые требуют большей работы на всех этапах разработки (проектировании, внедрении и тестировании), по-прежнему будут выпускаться только с основными релизами языка. И хотя окончательная минорная версия C# 7 еще не выпущена, команда уже активно работает над следующей основной версией языка: C# 8.0.

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


Nullable Reference Types

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

Основной идеей является позволить при определении типа переменной указать, может ли она являться null или нет:

IWeapon? canBeNull;
IWeapon cantBeNull;

Присваивание null или значения, которое потенциально может содержать null в не-null переменную, будет приводить к предупреждению компилятора (которое можно конфигурировать так, чтобы оно приводило к прерыванию дальнейшей компиляции, если нужна особая безопасность):

canBeNull = null;       // нет предупреждения
cantBeNull = null;      // предупреждение
cantBeNull = canBeNull; // предупреждение

Аналогично, предупреждения будут генерироваться при разыменовании nullable-переменных без их предварительной проверки на null:

canBeNull.Repair();       // предупреждение
cantBeNull.Repair();      // нет предупреждения
if (canBeNull != null) {
    cantBeNull.Repair();  // нет предупреждения
}


Примечание переводчика

Выглядит красиво, пока мы не вспоминаем про многозадачное окружение — как быть там в случае, если кто-то вклинился между проверкой и использованием?

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

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

Раннее превью данной возможности доступно для загрузки в Visual Studio 2017 15.6 update.


Records

В настоящий момент, значительная часть бойлерплейт-кода на C# написана во время создания простого класса, который работает как контейнер значений и не имеет никаких методов или бизнес-логики.

Синтаксис записей должен стандартизовать реализацию таких классов с абсолютным минимумом кода:

public class Sword(int Damage, int Durability);

Эта строка эквивалентна следующему классу:

public class Sword : IEquatable
{
    public int Damage { get; }
    public int Durability { get; }

    public Sword(int Damage, int Durability)
    {
        this.Damage = Damage;
        this.Durability = Durability;
    }

    public bool Equals(Sword other)
    {
        return Equals(Damage, other.Damage) 
            && Equals(Durability, other.Durability);
    }

    public override bool Equals(object other)
    {
        return (other as Sword)?.Equals(this) == true;
    }

    public override int GetHashCode()
    {
        return (Damage.GetHashCode() * 17 + Durability.GetHashCode());
    }

    public void Deconstruct(out int Damage, out int Durability)
    {
        Damage = this.Damage;
        Durability = this.Durability;
    }

    public Sword With(int Damage = this.Damage, int Durability 
        = this.Durability) => new Sword(Damage, Durability);
}

Как вы видите, этот класс содержит свойства, открытые только для чтения и конструктор для их инициализации. Он реализует сравнение по значению и корректно перекрывает GetHashCode для использования в хэш-коллекциях типа Dictionary и Hashtable. Есть даже метод Deconstruct для деконструкции класса на индивидуальные значения с использованием синтаксиса кортежей:

var (damage, durability) = sword;

Вы, вероятно, не узнали синтаксис, использованный в последнем методе класса. Параметры метода по умолчанию, дополнительно позволяют ссылаться на поле или свойство класса, используя данный синтаксис. Это, в частности, полезно для реализации хэлпера With, который предназначен для создания модифицированных копий существующих неизменяемых объектов:

var strongerSword = sword.With(Damage: 8);

Дополнительно, рассматривается следующий синтаксический сахар для работы с этим методом:

var strongerSword = sword with { Damage = 8 };


Recursive Patterns

Некоторые возможности сопоставления паттернов уже были добавлены в C# 7.0. В версии 8.0 планируется расширить их следующими кейсами:

Рекурсивные паттерны будут позволять деконструировать сопоставляемые типы в одно выражение. Это должно хорошо работать со сгенерированным компилятором методом для записей Deconstruct ():

if (sword is Sword(10, var durability)) {
    // код выполняется, если Damage = 10
    // durability равно значению sword.Durability
}

Паттерн кортежей (tuple) будет позволять сопоставлять более чем одно значение в одном выражении:

switch (state, transition)
{
    case (State.Running, Transition.Suspend):
        state = State.Suspended;
        break;
}

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

state = (state, transition) switch {
    (State.Running, Transition.Suspend) => State.Suspended,
    (State.Suspended, Transition.Resume) => State.Running,
    (State.Suspended, Transition.Terminate) => State.NotRunning,
    (State.NotRunning, Transition.Activate) => State.Running,
    _ => throw new InvalidOperationException()
};


Default Interface Methods

В настоящее время, C# не позволяет интерфейсам содержать реализации методов, только их декларации:

interface ISample
{
    void M1();                                    // разрешено
    void M2() => Console.WriteLine("ISample.M2"); // запрещено
}

Чтобы реализовать такую функциональность, вместо этого могут быть использованы абстрактные классы:

abstract class SampleBase
{
    public abstract void M1();
    public void M2() => Console.WriteLine("SampleBase.M2");
}

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

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

Множественное наследование запрещено в C#, поэтому класс может быть унаследован только от одного базового абстрактного класса.

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

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


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


Asynchronous Streams

C# уже имеет поддержку итераторов и асинхронных методов. В C# 8.0, планируется объединить эту пару в асинхронные итераторы. Они будут основаны на асинхронных версиях интерфейсов IEnumerable и IEnumerator:

public interface IAsyncEnumerable
{
    IAsyncEnumerator GetAsyncEnumerator();
}

public interface IAsyncEnumerator : IAsyncDisposable
{
    Task MoveNextAsync();
    T Current { get; }
}

Также, потребители асинхронной версии итераторов будут нуждаться в асинхронной версии IDisposable:

public interface IAsyncDisposable
{
    Task DisposeAsync();
}

Это позволит использовать следующий код для итерации по элементам:

var enumerator = enumerable.GetAsyncEnumerator();
try
{
    while (await enumerator.WaitForNextAsync())
    {
        while (true)
        {
            Use(enumerator.Current);
        }
    }
}
finally
{
    await enumerator.DisposeAsync();
}


Примечание переводчика

Примечание переводчика — не понял, зачем здесь цикл while (true). Или я чего-то не понял, или так возникнет бесконечный цикл. Подозреваю, что это просто опечатка, но мало ли. Оставил, как в оригинальной статье, которая прошла техническую рецензию по месту оригинальной публикации.

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

foreach await (var item in enumerable)
{
    Use(item);
}

Так же, как и в foreach выражении, компилятор будет генерировать необходимый код самостоятельно.

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

async IAsyncEnumerable AsyncIterator()
{
    try
    {
        for (int i = 0; i < 100; i++)
        {
            yield await GetValueAsync(i);
        }
    }
    finally
    {
        await HandleErrorAsync();
    }
}

Дополнительно ожидается поддержка токенов отмены и LINQ.


Ranges

Планируется добавить новый синтаксис для диапазона значений:

var range = 1..5;

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

struct Range : IEnumerable
{
    public Range(int start, int end);
    public int Start { get; }
    public int End { get; }
    public StructRangeEnumerator GetEnumerator();
    // overloads for Equals, GetHashCode...
}

Этот новый тип может быть эффективно использован в нескольких различных контекстах:


  • Он может возникать как аргумент в индексаторах, чтобы обеспечить более компактный синтаксис для срезов массивов:
Span this[Range range]
{
    get
    {
        return ((Span)this).Slice(start: range.Start, 
            length: range.End - range.Start);
    }
}


  • Поскольку эта структура реализует интерфейс IEnumerable, она может быть использована как альтернативный синтаксис для итерации по диапазону значений:
foreach (var index in min..max)
{
    // обработка значений
}


  • Сопоставление шаблонов также может использовать этот синтаксис для определения, что значение находится внутри указанного диапазона:
switch (value)
{
    case 1..5:
        // значение в диапазоне
        break;
}


Примечание переводчика

В первых двух сценариях неплохо было бы и аргумент step предусмотреть — такие сценарии встречаются не слишком часто, но регулярно. А еще — открытые диапазоны (для первого и третьего сценариев). Тогда диапазоны будут лучше соответствовать слайсам того же Python, что положительно скажется на унификации языковых средств.

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


Примечание переводчика

Если будет принято решение оставить один вариант, то я бы предпочел тот, что в питоне, т.е.:

a[start:end] # значения от start до end-1
a[:end]      # items от начала до end-1

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


Generic Attributes

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

public class TypedAttribute : Attribute
{
    public TypedAttribute(Type type)
    {
        // ...
    }
}

С поддержкой дженериков, тип может быть передан как обобщенный аргумент:

public class TypedAttribute : Attribute
{
    public TypedAttribute()
    {
        // ...
    }
}

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

public TypedAttribute(T value)
{
    // ...
}


Default Literal in Deconstruction

Чтобы присвоить значения по умолчанию всем членам кортежа в C# 7, необходимо было использовать следующий синтаксис:

(int x, int y) = (default, default);

С введением поддержки для литерала default, синтаксис аналогичного выражения может быть упрощен до:

(int x, int y) = default;


Caller Argument Expression

Начиная с C# 5, введены атрибуты CallerMemberName, CallerFilePath и CallerLineNumber) которые упростили работу с информацией о точке вызова для целей диагностики.

Атрибут CallerMemberName также оказался очень полезным для реализации интерфейса INotifyPropertyChanged:


Примечание переводчика

Но до удобства PropertyChanged.Fody ему далеко.

class ViewModel : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;

    private void OnPropertyChanged([CallerMemberName] string propertyName = null)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }

    private int property;

    public int Property
    {
        get { return property; }
        set
        {
            if (value != property)
            {
                property = value;
                OnPropertyChanged();
            }
        }
    }
}

C# 8 может дополнительно ввести поддержку похожего атрибута CallerArgumentExpression, который позволяет установить целевой аргумент в строковое представление выражения, которое передано как значение для другого аргумента в этом же вызове методе:

public Validate(int[] array, [CallerArgumentExpression("array")] string arrayExpression = null)
{
    if (array == null)
    {
        throw new ArgumentNullException(nameof(array), $"{arrayExpression} was null.");
    }
    if (array.Length == 0)
    {
        throw new ArgumentException($"{arrayExpression} was empty.", nameof(array));
    }
}

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


Target-typed new Expression

При декларации локальных переменных, ключевое слово var уже используется для предотвращения повторений (потенциально длинных) названий типов в коде:

Dictionary dictionary = new Dictionary(); // без var
var dictionary = new Dictionary(); // с var

Аналогичный подход не может быть использован для членов класса (полей), поскольку они требуют, чтобы тип был явно определен:

class DictionaryWrapper
{
    private Dictionary dictionary = new Dictionary();
    // ...
}

Планируемое в C# 8, типизируемое целевым типом выражение new, даст альтернативный сокращенный синтаксис для таких случаев:

class DictionaryWrapper
{
    private Dictionary dictionary = new();
    // ...
}

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


Крик души переводчика

Да сделайте уже аналог Type/typedef, который применим в гораздо большем количестве сценариев и забудьте про все эти мелкие синтаксические сахаринки, которые новички будут учить еще долго! А такой вот инициализатор не подойдет даже для случая, когда поле/переменная будут иметь интерфейсный тип.


Ordering of ref and partial Modifiers on Type Declarations

C# в настоящий момент требует, чтобы ключевое слово partial было размещено непосредственно перед ключевыми словами struct или class.

При введении ключевого слова ref для аллоцируемых на стеке структур в C# 7.2, ограничения на слово partial остались в силе, что ведет к тому, что слово ref должно размещаться непосредственно перед ключевым словом struct, если перед ним нет partial и непосредственно перед partial, если последнее присутствуют.

Следовательно, валидны только два варианта синтаксиса:

public ref struct NonPartialStruct { }
public ref partial struct PartialStruct { }

В C# 8, эти ограничения планируется ослабить, чтобы следующий вариант также был валидным:

public partial ref struct PartialStruct { }


Заключение

В C# 8 планируется много новых возможностей. Данный обзор рассказывает не обо всех из них.
Вероятно, мы еще довольно далеко от финального релиза C# 8.0, поскольку его дата еще не была объявлена. До этого вполне мы можем ожидать, что планы изменятся, не все возможности войдут в релиз. И даже те, что войдут в финальный релиз, могут быть изменены синтаксически и даже семантически.

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

Также рекомендуем прочитать статью C# 7.1, 7.2 and 7.3 — New Features (Updated).


Примечание переводчика

Как переводчик, позволю добавить своё впечатление о развитии C#. В свое время уже писал об этом в статье C# — есть ли что-то лишнее? За прошедшее время, произошли некоторые изменения в ощущениях и понимании:


  1. Насчет var попустило. Его использование не сильно мешает мне читать чужой код. Только в редких случаях. Значит, я слишком беспокоился.
  2. Язык Go легко учится. Это позволяет легко войти. Но при этом приходится писать достаточно много бойлерплейт-кода. Особенно раздражает это в случаях, когда знаешь, что это легко обойти (теми же дженериками) на других языках.
  3. Язык C# очень сложный и становится все сложнее. Поскольку я с ним почти с самого зарождения (еще с версии .Net Framework 1.1), то я нормально успеваю впитать новшества (разве что «человекопонятный» синтаксис LINQ так и не зашел — возможно потому, что знаю SQL, под который он неудачно пытался мимикрировать). При этом наблюдаю, как годами учат язык новички.
  4. Так и не вводят какого-то аналога typedef, который бы снял необходимость во многих фичах сокращенного определения сложных контейнерных типов, позволил бы вводить алиасы для типов, использовать их в определениях членов класса (var работает только для локальных элементов). Вместо этого делают сокращенный new (), который применим в узком диапазоне сценариев (замечание про инициализацию переменной интерфейсного типа смотри в спойлере выше).

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

Как это решить? За каждым языком тянется хвост обеспечения совместимости. Именно он не дает возможности удалять уже введенные фичи. Но у C# есть хорошая база — Common Language Infrastructure (CLI). CLI дает возможность написать новый язык с чистого листа, оставив при этом возможность работы со старым кодом, который еще долго может оставаться в рабочем состоянии. В качестве ближайшего аналога можно рассмотреть ситуацию с Java/Kotlin. Возможно, Майкрософт пойдет таким путем?

Также для постепенного выпиливания устаревших возможностей можно использовать указание версии языка (упомянутое в разделе о Nullable Reference Types). Такой подход был бы более эволюционным, но и очень продолжительным.

© Habrahabr.ru