Дизайн и эволюция свойств в C# (часть 1)
Идея поста родилась из обычной рабочей задачи — поддержать нововведения языка C# 6.0 в ReSharper. Как обычно, все задачи в ReSharper оказываются в 5-10 раз сложнее, чем кажется на первый взгляд. Особой головной болью оказалась поддержка новых возможностей свойств в C# 6.0, так как изменения языка затрагивали массу кода имеющейся функциональности (далеко не всегда очевидным образом). Переделки заняли несколько месяцев, иногда вынуждая переписывать некоторые рефакторинги практически целиком (конкретно — “Convert property to auto-property”), что заставило меня задуматься — почему все настолько сложно в мире свойств C#? Как так получилось, что работая со свойствами C# в IDE-инструментарием, надо постоянно держать в голове просто массу знаний о них? Чувствуют ли эту “сложность” обычные программисты?
Итак, сегодня я предлагаю вам в деталях обсудить понятие “свойства” на примере языка C# с самой первой его версии, немного поразмышлять о дизайне языков программирования, о том куда этот дизайн может катится и о том, можно ли хоть что-нибудь исправить.
Ликбез по свойствам
Что это вообще такое?
Давайте вернемся во времена C# 1.0 и рассмотрим определение канонического DTO-класса, инкапсулирующего некоторые данные. В отличие от Java, объявления класса в C# могла содержать не только поля/методы/классы, но и еще одну разновидность членов класса — объявления свойств:
class Person {
private string _name;
public Person(string name) {
_name = name;
}
// property declaration:
public string Name {
get { return _name; }
set { _name = value; }
}
}
// property usage:
Person person = new Person();
person.Name = "Alex";
Console.WriteLine(person.Name);
person.Name += " the cat";
Свойства представляю собой члены класса, обладающие именем и типом, а так же содержащие в себе объявления “аксессоров”. Акцессоры немного похожи на объявления методов, за исключением, например, явной указания типа возвращаемого значения и списка формальных параметров. Существуют два вида аксессоров свойств — getter’ы и setter’ы, вызываемых при обращении к свойству на чтение и запись соответственно. Возможно, на тот момент это казалось языковым средством неземной красоты, по сравнению со шляпой из пары методов в Java (которую шлепают до сих пор, в 2015 году):
class Person {
private String _name;
public String getName() {
return _name;
}
public void setName(String value) {
_value = value;
}
}
Person person = new Person();
person.setName("Alex");
Console.WriteLine(person.getName());
person.setName(person.getName() + " the cat");
Мотивация
ОК, зачем нам вообще нужны свойства? Дело в том, что в высокоуровневых языках, таких как в Java и C#, поля классов представляют собой достаточно низкоуровневые конструкции1. Доступ к полю просто считывает или записывает область памяти некоторого известного размера по некоторому смещению, статически известному среде исполнения. Из такой низкоуровневости полей следует, что:
-
Между разными полями и полями разных типов не существует унифицированного “интерфейса” (такого, например, как указатель на исполняемый код), что не позволяет организовать к ним полиморфный доступ (написать код, абстрагированный от знания о конкретном типе, к полю которого он обращается);
-
Не существует возможности перехвата обращений к полю для исполнения дополнительных проверок консистентности, инварианта класса. На практике это же означает невозможность отладить программу, перехватив обращения к полям на чтение/запись через точку прерывания.
Помимо этого, практика показывает, что в классах часто удобно выставлять наружу какие-либо данные, не имея их в своем состоянии — то есть вычислять данные по какому-нибудь правилу при каждом обращении, забирать у какого-нибудь внутреннего объекта и т.п.
Все эти проблемы можно было успешно решить существующими в языке средствами — введя пару методов для доступа к значению поля, реализуя ими члены интерфейса или исполняя произвольный код проверок до или после записи:
interface INamedPerson {
string GetName();
SetName(string value);
int GetYear();
}
class Person : INamedPerson {
private string _name;
public string GetName() { return _name; }
public void SetName(string value) {
if (value == null) throw new ArgumentNullException("value");
_value = value;
}
public int GetYear() {
return DateTime.Now.Year;
}
}
Чем неудобно такое решение?
- Мы потеряли привычный синтаксис доступа к данным в полях:
foo.Value += 42;
// vs
foo.SetValue(foo.GetValue() + 42);
-
В программе на самом деле никак не выражено, что все три сущности — поле и пара методов — имеют какую-либо связь. Методы и поля могут иметь разный уровень видимости, разное имя, разную “статичность” и “виртуальность”.
-
Чтобы намекнуть на общую связь, мы объявили три сущности имеющими подстроку “name” в именах. При рефакторинге нам придется обновить все три имени. Аналогично с упоминанием типа данных. Подобное соглашение об именовании упрощает жизнь в Java, но носит лишь рекомендательный характер (компилятор не будет бить по рукам в случае игнорирования соглашений).
Решение с помощью свойств
Давайте посмотрим на пример кода выше, переписанный с использованием объявлений свойств C#:
interface INamedPerson {
string Name { get; set; }
int Year { get; }
}
class Person : INamedPerson {
private string _name;
public string Name {
get { return _name; }
set {
if (value == null) throw new ArgumentNullException("value");
_name = value;
}
}
public int Year {
get { return DateTime.Now.Year; }
}
}
Кода по-прежнему невыносимо много, но определенные удобства свойства все же привносят:
-
Тела аксессоров синтаксически объединены в один блок, а значит логически разделяют один и тот же уровень видимости, модификаторы статичности и виртуальности;
-
Подстрока “name” теперь встречается в объявлениях “всего” два раза, так же как и тип
String
. Параметрvalue
объявляется вset
-аксессоре неявным образом, что тоже экономит немного кода; -
Синтаксис доступа к свойствам аналогичен привычному и простому синтаксису доступа к полю, что еще и значительно облегчает инкапсуляцию поля в свойство вручную (без автоматизированных рефакторингов кода);
-
За свойством может и вовсе не стоять поля с состоянием, тела аксессоров могут содержать произвольный код;
-
Не смотря на то, что аксессоры все равно компилируются в тела методов
string get_Name()
иset_Name(string value)
, в метаданных хранится специальная запись о свойстве, как о единой сущности (аналогично для событий C#). Таким образом понятие “свойства” существует для среды исполнения, это не только сущность компилятора C#. Как следствие, например, свойства можно помечать атрибутами CLI как единую сущность, что имеет множество применений на практике.
Как я не пытался структурировать дальнейшие рассуждения, дальше получились просто перечислить приемущества и недостатки свойств вообще и дизайна свойств конкретно в языке C# самой первой версии.
Set-only свойства
Тут нечего особо обсуждать — в C# никогда не должно было случиться свойств из единственного set
-аксессора:
class Foo {
public int Property {
set { SendToDeepSpace(value); }
}
}
Дизайн языка программирования можно сравнить с многомерной задачей оптимизации. Найдя локальный максимум функции от многих переменных (гибкость, функциональность, каноничность, синтаксическая красота и многое другое) может казаться, что выбранный дизайн достаточно канонический. Однако, всегда есть вероятность, что пожертвовав максимумом по некоторому измерению, можно прийти в более разумный максимум по всем измерениям.
Например, запрет объявления свойств из одного set
-аксессора может казаться натуральной “заплаткой” с точки зрения красоты спецификации языка, каким-то искусственным ограничением, разрушающим симметрию между разными типами аксессорами2 (get
-аксессоры становятся обязательными).
С другой стороны, если не запрещать такие очевидно странные сущности (на такие set
-only свойства в языке похожи только out
-параметры, но их становится возможно считывать после первого присвоения), то начинает случаться реальный говнокод с set
-only свойствами (я пару раз встречал). Если пару раз наткнуться на такие свойства, не понимая почему их значение не видно в отладчике, то начинаешь ценить совсем не каноничность определения свойства в спецификации, а в количество фашизмазапретов в компиляторе.
Разновидности доступа
Простой императивный язык программирования — это такой, в коде которого можно увидеть использование (не объявление!) переменной foo
и всегда знать, что это либо чтение переменной, либо запись. Но когда-то очень давно случился язык программирования C и теперь у нас есть вот эти мутанты:
variable ++;
variable += 42;
То есть появляется новая разновидность использования — чтение/запись3. Так как C# стремится синтаксически устранить различия использования свойств от использования полей, то подобные операторы разрешены и для свойств (доступных для записи), компилируясь в вызовы get
и set
-аксессоров:
++ person.Age;
// is
person.set_Age(person.get_Age() + 1);
Казалось бы: замечательно, что это работает — ведь на то и нужен язык высокого уровня, что скрывать низкоуровневые детали реализации свойств как вызовов методов. Проблема в том, что в C# есть еще один источник использований на чтение и запись одновременно — ref
-параметры:
void M(ref int x) { x += 42; }
int x = 0;
M(ref x); // read-write usage
К сожалению, ref
/out
-параметры в C# — не менее низкоуровневые конструкции, чем поля типов. Для среды исполнения ref
/out
-параметры имеют специальный управляемый ссылочный тип, отличающегося от обычных unmanaged-указателей только запретом на арифметический операции и осведомленностью GC об объектах по таким указателям (передача поля класса/элемента массива как ref
/out
-параметра удерживает весь объект/массив от сборки мусора).
Из-за невозможности превратить два метода аксессоров свойства в один указатель на изменяемую область памяти, компилятор C# банально не позволяет передавать свойства в ref
/out
-параметры. Это редко нужно на практике, но выглядит как “спонтанное нарушение симметрии” в языке. Интересно, что другой .NET-язык — Visual Basic .NET — без особых проблем скрывает для пользователя разницу между свойствами и полями:
sub F(byref x as integer)
x += 1
end sub
interface ISomeType
property Prop as integer
end interface
dim i as ISomeType = ...
F(i.Prop) ' OK
Грубо говоря, VB.NET разрешает передавать в byref
-параметры вообще любые выражения, автоматически создавая временную локальную переменную (и передавая ее адрес). Если в качестве аргумента byref
-передано изменяемое свойство, то VB.NET автоматически присвоит свойству значение временной переменной, но только по окончанию вызова. Есть вероятность (крайне маленькая), что метод с byref
-параметром каким-нибудь магическим образом должен зависеть от актуального значения переданного в него свойства и тогда удобность превратится в грабли.
Но граблей и без этого хватает: например, если взять в скобки i.Prop
из примера выше, то присвоение свойству перестанет происходить (в качестве byref
-аргумента начнет передаваться временное значение выражения в скобках, а не само свойство-как-адрес). Помимо этого, присвоение свойству не случится если после присвоения byref
-параметра в методе возникнет исключение. Вот и не понятно становится — стоят-ли эти грабли в языке потерянной универсальности?
1 В CLR чтение полей MarshalByReference
объектов на самом деле всегда виртуальное (до тех пор пока его не передать в ref
/out
-параметр).
2 В C# уже существуют другие типы членов класса с аксессорами — события — для которых компилятор всегда требует определить оба ацессора (add
и remove
).
3 На самом деле я конечно не упоминаю другие типы использований сущностей в C# — использования в XML-документации, использования имен сущностей в операторе nameof()
из C# 6.0, “частичные” использования на чтение и запись при работе с типами-значениями:
Point point;
point.X = 42; var x = point.X;
// | |__ write | |__ read
// |________ partial write |________ partial read