C# 6.0 - одумались?

Последий (на момент написания статьи) релиз языка C# 6.0 ознаменовал завершение более чем пятилетнего переписывания компиляторов C# и VB.NET на управляемый код, в ходе которого команда не ставила перед собой задачу внесения в язык каких-либо “больших” языковых средств. Сфокусировавшись на относительно мелких улучшениях, C# 6.0 делает попытки исправить значительные недостатки дизайна свойтств, что не может не радовать.

Свойства с телом-выражением

Программируя на языках с C-подобным синтаксисом, все мы сталкивались с большим количеством тривиальных методов-“однострочников”, особенно состоящих из единственного return-statement’а. C# 6.0 пытается адресовать проблему синтаксического шума из-за необходимости в блоке и statement’е, вводя для объявлений методов (и операторов, но не конструкторов) синтаксис тел-выражений, аналогичных привычному C# разработчикам синтаксису тел лямбда-выражений:

class C {
  public int StatementBodiedMethod(int x) { return x + 42; }
  public int ExpressionBodiedMethod(int x) => x + 42;
}

Конечно же, дизайнеры языка C# не могли обойти стороной и синтаксис более сложных (синтаксически) членов типов, имеющих внутри себя объявления аксессоров - свойства и индексаторы:

class C {
  public int StatementBodiedProperty { get { return 42; } }
  public int ExpressionBodiedProperty => 42;

  public string this[int index] { get { return index.ToString(); } }
  public string this[int index] => index.ToString();
}

Как можно заметить, в этом случае синтаксический сахар тел-выражений скрывает за собой немного больше, чем в случае методов и операторов - отсутствует явное объявление get-аксессора. Таким образом, в синтаксис с телом-выражения могут быть переписаны свойства только с единственным get-аксессором (с public уровнем доступа) без атрибутов:

abstract class A {
  public virtual int Value { protected get; set; }
  public virtual string Text { get; set; }
}

class C : A {
  public override int Value => 42; // error
  public override string Text {
    [SomeAttribute] get { return "abc"; }
  }
}

Оба случая крайне редкие (за первый надо жестоко наказывать), поэтому ничего страшного в подобных ограничениях нет, лишь немного больше работы для IDE. Однако, из-за таких мелочей мы на самом деле не можем полагаться, что каждое свойство только для чтения можно переписать в вид с телом-выражением. Более того, не смотря на синтаксис, который как-бы говорит “я - вычисляемое свойство только для чтения”, из-за описанных ранее полиморфных аксессоров, свойство с телом-выражением вполне может быть доступно и для записи:

class A {
  public virtual int Value { get; set; }
}

class C : A {
  public override int Value => 42; // readonly? no
  public void M() {
    Value ++; // ok
  }
}

Немного о синтаксисе тел-выражений

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

class C : B {
  public override bool CanRead {
    get {
      return true;
    }
  }
  // vs.
  public override bool CanRead {
    get { return true; }
  }
  // vs.
  public override bool CanRead { get { return true; } }
  // vs.
  public override bool CanRead => true;
}

Разобравшись немного получше, задумываешсья что синтаксис тел-выражений не позволит (или как минимум затруднит) в будущем ввести какие-нибудь новые модификаторы на аксессорах, но таких пока не приходит в голову (может быть какой-нибудь lazy?). Задумавшись еще глубже, становится страшно представлять как придется объяснять новичку в C# в чем разница между двумя языковыми конструкциями, различающиеся буквально одним символом:

class C {
  public int Field = 42;
  public int Property => 42;
}

Почему после = выражение находится в статическом контексте и вычисляется при инициализации класса, а выражение после => вычисляется каждый раз и позволяет пользоваться this? Синтаксис тел-выражений смазывает различие между совершенно разными конструкциями, предназначенными для разных целей, только с опытом программирования на C# вырабатывается, в некотором роде, ожидание от конструкции с токеном => как от чего-то вычисляемого. Еще одна проблема, общая с синтаксисом тел лямбда-выражений - невозможность выбросить исключение в теле-выражении:

class C {
  public int Property =>
    throw new NotImplementedException(); // unexpected 'throw' token
}

Это создает банальную проблему для IDE - непонятно какого вида “заглушку” генерировать в телах генерируемого кода, если пользователь будет требовать от IDE предпочитать форму свойств с телом-выражением. Еще одна проблема, касающаяся IDE - объявления свойств (и индексаторов) теперь иногда становятся для IDE полноценными “функциями” - конструкциями, содержащими в себе исполняемый код. До этого, в C# свойства являлись лишь контейнерами объявлений аксессоров, которые уже в свою очередь содержали исполняемый код. Это вынуждает переписать всю функциональность, вычисляющую по синтаксическому дереву в каком свойстве/аксессоре находится тот или иной исполняемый код.

Инициализаторы авто-свойств

Второй вкусностью, привнесенной C# 6.0 является синтаксис инициализаторов авто-свойств, полностью экивалентный синтаксису инициализаторов полей и field-like событий (в ходе разработки C# 6.0 чуть не упустили инициализаторы массивов, редко используемые на практике, но важные с точки зрения симметрии с объявлениями полей/событий):

class Person {
  public string Name { get; set; } = string.Empty;
  public int[] Array { get; set; } = { 1, 2, 3 }; // array initializer
}

Из-за первоначального синтаксиса свойств, не требовавшего символ ; в конце объявления свойств (в отличие от полей и field-like событий), маленьким “костылем” является то, что синтаксически символ ; в инициализаторе авто-свойства относится к самому иницализатору, а не объявление свойства. Поэтому, например, отличается процедура удаления инициализаторов полей и авто-свойств, по-разному работают IDE-функциональность типа “extend selection”.

Еще одним маленьким нюансом является то, что авто-свойства практически не отличаются синтаксически от обычных объявлений свойств (используют те же правила грамматики C#), что вынуждает парсер всегда ожидать и разбирать синтаксис инициализатора после объявлений аксессоров любого свойства, позже генерируя ошибку компиляции в случае, если инициализируют не авто-свойство. Более того, вообще ответ на вопрос “а авто-свойство ли это?” в IDE не так прост, как кажется - отсутствие тел у объявлений аксессоров встречается у абстрактных и интерфейсных свойств.

Что касается семантики, то инициализаторы авто-свойств работают аналогично инициализаторам полей - инициализация случается до вызова тела конструктора (включая вызов базового конструктора). Из этого следует простое правило пересечения с полиморфизмом - инициализатор записывает значение в поле авто-свойства, минуя set-аксессор (потенциально виртуальный), иначе это привело бы к виртуальным вызовам до вызова конструктора производного класса.

class Base {
  public virtual string Text { get; set; } = string.Empty;
}

class Derived : Base {
  public override string Text {
    set { throw new InvalidOperationException("Do not mutate me!"); }
  }
}

var derived = new Derived(); // ok
Assert.AreEqual(derived.Text, string.Empty); // ok

Из-за появления неполиморфного доступа к полиморфному члену с состоянием, мы наследуем и часть проблем виртуальных field-like событий. Например, инициализированное авто-свойство могут полностью переопределить и состояние авто-свойства (пустой список из примера ниже) станет недостежимым, даже внутри объявления класса Base:

class Base {
  public virtual List<T> Items { get; set; } = new List<T>();
}

class Derived : Base {
  public override List<T> Items { get { ... } set { ... } }
}

Создается ощущение, что таким образом у нас “протекла” абстракция авто-свойства - мы “узнали” о существовании поля. Настолько ли это плохо? Не думаю. Опытный читатель мог заметить, что этой возможности добраться напрямую до поля авто-свойства нам не хватало чтобы удовлетворить анализ инициализации структуры. Это действительно так но, к сожалению, из-за запрета инициализаторов на членах экземпляра в объявлениях структуры, проблему анализа инициализации структуры решить через инициализаторы авто-свойств не получится.

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

class C {
  public static readonly int Field = EvaluatedFirst();
  public static int AutoProperty1 { get; set; } = EvaluatedSecond();
  public static int AutoProperty2 { get; set; } = EvaluatedThird();
  public static event EventHandler Event = EvaluatedFourth();
}

Авто-свойства только для чтения

TODO: нарушили симметрию аксессоров

Следующим позитивным изменением C# 6.0, относящемуся к объявлениям свойств, стала возможность опускать объявление set-аксессора авто-свойств, тем самым делая их “по-настоящему” неизменяемыми. Это изменением наделяет бОльшим смыслом инициализаторы на объявлениях свойств, позволяя “натурально” инициализровать такие авто-свойства только для чтения:

class Person {
  public string Name { get; }
  public int Age { get; }

  public List<Item> Items { get; } = new List<Item>();

  public Person(string name) {
    this.Name = name;
  }
}

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

Я крайне рад такому дизайну (разрешение присвоения внутри конструкторов появилось не сразу в ходе разработки C# 6.0), однако вынужден признать, что внутри IDE-инструментария обработка таких авто-свойств превращается в настоящий “хак” - процедура выяснения, можем ли мы записать в свойство, вместо простого поиска set-аксессора и выяснения его доступности теперь становится более контекстно-зависимой и “знающей” о понятии авто-свойства. Можно долго спорить: имеет ли сложность построения IDE-инструментария прямое отношение к сложности языка для конечного пользователя или нет, но не мне одному присвоение свойству без set-аксессора по-началу кажется странным.

Возвращаясь к хорошим сторонам авто-свойств для чтения, возможность опустить set-аксессор позволяет нам использовать авто-свойства для переопределений свойств с единственным get-аксессором, что просто не было возможно раньше:

abstract class A {
  public abstract int ReadOnly { get; }
}

class C : A {
  public override int ReadOnly { get; } // ok
}

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

abstract class A {
  public abstract int MutableProperty { get; set; }
}

class C : A {
  // CS8080: Auto-implemented properties must override
  //         all accessors of the overridden property
  public override int MutableProperty { get; }
}

Так как присвоение авто-свойству для чтения на самом деле компилируется, очевидно, напрямую в присвоение полю авто-свойства, то интересной особенностью начинает обладать доступ на чтение/запись, иногда становясь “наполовину полиморфным”:

class C {
  public virtual int GetOnlyAutoProp { get; }
    
  public C() {
    this.GetOnlyAutoProp += 42;
    // compiled into:
    this.<GetOnlyAutoProp>k__BackingField = this.GetOnlyAutoProp + 42;
  }
}

Из негативных сторон дизайна авто-свойств только для чтения так же стоит отметить отсутствие всяких предупреждений компилятора в случае отсутствия присвоения таким авто-свойствам в конструкторах (аналогичных предпреждениям компилятора для readonly полей) или инициациализатором. Я не могу объяснить отсутствие подобного предупреждения, разве только желанием подбросить работы разработчикам IDE-инструментария.

class C {
  public int LostState { get; } // compiler: OK
                                // resharper: Unassigned get-only auto-property
}

Еще одним минусом является плохое пересечение с типами-значениям: доступ к авто-свойству-только-для-чтения внутри конструктора не классифицируется как переменная (в отличие от readonly-полей), что запрещает доступ на частичную запись таких авто-свойств:

public struct Point {
  public int X, Y;
}

public class C {
  public Point Origin { get; }

  public C() {
    Origin.X = 1; // CS1612: Cannot modify the return value of 'C.Origin'
    Origin.Y = 2; //         because it is not a variable
  }
}

Авто-свойства и определения структур

Ну и последним позитивным изменением авто-свойств стал просто-напросто “захаканный” анализ инициализации структуры. Выше было упомянуто, что присвоении авто-свойству только для чтения на самом деле “означает” присвоение его полю, поэтому такие авто-свойства изначально бы не создавали проблем внутри структур (инициализация авто-свойства не считалась бы доступом к this структуры до инициализации всех ее полей):

struct Point {
  public int X { get; }
  public int Y { get; }
    
  public Point(int x, int y) {
    this.X = x; // OK
    this.Y = y;
  }
}

Но C# 6.0 затыкает исходную проблему и для обычных изменяемых авто-свойств с обоими аксессорами, просто “считая” присвоение авто-свойству (этой структуры) за инициализацию поля авто-свойства. Это изменением стоит рассматривать как позитивное, не смотря на работу в компиляторе и IDE-инструментарии (чтобы “обмануть” анализ инициализации) - возможно, будущие C#-программисты вообще не будут знать про существование этой проблемы в прошлом.

Стоит заметить, что C# 6.0 по-прежнему не разрешает инициализацию вложенных структур в авто-свойствах через частичную запись, так как свойства по-прежнему не классифицируются как переменная:

struct TwoPoints {
  public Point A { get; set; }
  public Point B { get; set; }
  
  public TwoPoints(int ax, int ay, int bx, int by) {
    A.X = ax; // CS1612: Cannot modify the return value
    A.Y = ay; // of 'TwoPoints.A' because it is not a variable
    B.X = bx;
    B.Y = by;
  }
}

Успешны ли изменения C# 6.0?

  • TODO: стало сложнее отличать стейт от не-стейта
class StateOrNot {
  int P { get; }
  int P { get; } = e;
  int P { get; set; }
  int P { get; set; } = e;
  int P { get { ... } }
  int P { get { ... } set { ... } }
  int P => e;
}
  • TODO: get-only auto-property explicit implementation + невозможность инициализировать
class C : IFoo {
  int IFoo.Property { get; } // get-only auto-property

  public C(int number) {
    ((IFoo) this).Property = number; // error
  }
}
  • TODO: field-target не сделали

Продолжение следует…