Поскольку мне говорят (точнее пишут), что я немного утомил с философией программирования и стоит браться за ум вспомнить и о технологиях, то я решил не откладывать это дело в долгий ящик, и опубликовать несколько заметок о языке C#. Тем более, что после подготовки к Hot Code остались некоторые наработки, которыми будет интересно поделиться.
Итак, у нас есть структура BigDouble с оператором неявного приведения типов к double и список этих структур:
struct BigDouble
{
private readonly double _value;
public BigDouble(double value) { _value = value; }
public static implicit operator double(BigDouble value)
{ return value._value; }
}
var bigDoubles = new List<BigDouble>
{
new BigDouble(42.0),
new BigDouble(18.0),
};
Вопрос #1. Будет ли работать следующий цикл foreach?
foreach (double d in bigDoubles)
{
Console.WriteLine(d);
}
Вопрос #2. Что мы получим в следующих случаях?
var query = from double d in bigDoubles
select d;
foreach (var d in query) { Console.WriteLine(d); }
Ответ #1
При описании особенностей цикла foreach я как-то упустил один интересный момент. Мы обсудили, что "утиное" поведение цикла foreach обусловлено тем, что в языке C# 1.0 не было обобщений (generics), а использование лишь интерфейса IEnumerable/IEnumerator приводило бы к упаковке на каждой итерации цикла.
Но есть еще одна особенность цикла foreach, корни которой уходят в эту же эпоху: возможность приведения типов переменной цикла. Так, при работе со слаботипизированой коллекцией, типа ArrayList, мы могли написать так:
ArrayList al = new ArrayList();
al.Add("foo");
foreach (string s in al) { Console.WriteLine(s); }
Эта возможность придавала работе с нетипизированными коллекциями некоторую форму строгой типизации. Можно подумать, что эта возможность работает только для приведения вверх/вниз по иерархии наследования (up/down casts), но поскольку в языке C# нет специализированных средств для такого приведения типов (нет аналога конструкции dynamic_cast языка C++), то это преобразование реализовано с помощью обычного преобразования типов (псевдокод):
using (var enumerator = collection.GetEnumerator())
{
while (enumerator.MoveNext())
{
// Переменная цикла объявляется внутри каждой итерации
// только в C# 5.0!
T current = (T)enumerator.Current;
// Тело цикла!!
}
}
(где T – тип переменной цикла foreach).
А раз так, то в цикле foreach можно использовать пользовательские преобразования типов, в результате код, приведенный в первом вопросе, будет успешно компилироваться и исполняться.
Ответ #2
LINQ запрос вида:
var query = from double d in bigDoubles
select d;
Преобразуется в вызов метода Enumerable.Select:
var query = bigDoubles.Cast<double>().Select(d => d);
И при выполнении любой из этих запросов “упадет” InvalidCastException. Причина в следующем. Enumerable.Cast является методом расширения интерфейса IEnumerable (а не IEnumerable<T>), а это значит, что внутри идет попытка преобразования не BigDouble -> Nullable<double>, а упакованного объекта BigDouble, т.е. мы пытаемся преобразовать (object)BigDouble -> double.
Как мы знаем (а если не знаем, то можем узнать в статье "Распаковка и InvalidCastException"), мы не можем менять тип упакованного объекта при распаковке, поэтому даже попытка приведения упакованного int к long "падает" с InvalidCastException, и даже следующий код завершается с исключением:
Enumerable.Range(1, 10).Cast<long>().ToList();
Решений этой проблемы несколько. Самый простой способ – это добавить явное преобразование типа с помощью Enumerable.Select:
foreach (double d in bigDoubles.Select(bd => (double)bd))
{ }
Или же можно сделать пару собственных методов расширения, которые будут выполнять необходимое преобразование внутри. Но это не так просто сделать, как кажется и наивная реализация следующего вида работать не будет:
public static IEnumerable<TTo> NaiveCast<TFrom, TTo>(
this IEnumerable<TFrom> enumerable)
{
// Используем возможность цикла foreach
// "изменять" тип переменной цикла
foreach (TTo value in enumerable)
{
yield return value;
}
}
Проблема в этом случае в том, что типы TFrom и TTo с точки зрения компилятора никак не связаны, поэтому такое преобразование в общем случае невозможно. Мы можем добавить ограничение where TFrom : TTo, но и это не поможет, поскольку это ограничение определяет отношение наследования между типами TFrom и TTo, но не учитывает возможность пользовательских преобразований и мы не сможем преобразовать список BigDouble к double.
Вместо этого, мы должны создать обобщенный метод, который будет "генерировать" код преобразования типов во время исполнения. Тут тоже есть несколько вариантов. Первый вариант – это использование dynamic (метод DynamicCast), второй – деревьев выражений (ExpressionCast):
public static class EnumerableEx
{
public static IEnumerable<TTo> DynamicCast<TFrom, TTo>(
this IEnumerable<TFrom> enumerable)
{
foreach (TFrom current in enumerable)
{
yield return (TTo)((dynamic)current);
}
}
public static IEnumerable<TTo> ExpressionCast<TFrom, TTo>(
this IEnumerable<TFrom> enumerable)
{
foreach (var current in enumerable)
{
TTo cur = DynamicConverter<TFrom, TTo>.Convert(current);
yield return cur;
}
}
}
internal static class DynamicConverter<TFrom, TTo>
{
// Если генерация конвертора упадет с исключением,
// то вместо TypeLoadException мы получим оригинальное исключение
private static readonly Lazy<Func<TFrom, TTo>> _converter =
new Lazy<Func<TFrom, TTo>>(GenerateConverter);
public static TTo Convert(TFrom valueToConvert)
{
return _converter.Value(valueToConvert);
}
private static Func<TFrom, TTo> GenerateConverter()
{
// Генерируем лямбда-выражение вида
// Func<TFrom, TTo> fun = (TFrom value) => (TTo)value;
var param = Expression.Parameter(typeof(TFrom));
Expression convertExpr = Expression.Convert(
param,
typeof(TTo));
var fun = Expression.Lambda<Func<TFrom, TTo>>(convertExpr, param).Compile();
return fun;
}
}
После чего мы сможем преобразовать последовательность одного типа в последовательность другого типа (хотя в продакшне я все равно предпочел бы увидеть простой вызов метода Select):
bigDoubles.ExpressionCast<BigDouble, double>();
Об опасностях неявного преобразования
Структура похожая на BigDouble использовалась в одном из реальных проектов, но она содержала пару неявных преобразований: к Nullable<double> и из Nullable<double>, в результате использования которой возникла небольшая проблема.
Предположим, у нас есть следующая вью-модель (ради наглядности я привожу и модифицированный код структуры BigDouble):
struct BigDouble
{
private readonly double? _value;
public BigDouble(double? value) { _value = value; }
public static implicit operator double?(BigDouble value)
{
return value._value;
}
public static implicit operator BigDouble(double? value)
{
return new BigDouble(value);
}
}
class CustomViewModel
{
public BigDouble Value1 { get; set; }
public BigDouble Value2 { get; set; }
public BigDouble Total { get { return Value1 + Value2; } }
}
Где-то в коде мы получаем список наших вью-моделей и фильтруем их следующим образом:
var viewModels = new List<CustomViewModel>{
new CustomViewModel {Value1 = new BigDouble(42.0)},
new CustomViewModel {Value2 = new BigDouble(12.0)},};
var filteredVMs = viewModels.Where(vm => vm.Total > 0).ToList();
При этом автор этого кода рассчитывал увидеть в результирующем списке обе вью-модели. А сколько он получит в результате?
...
(да, filteredVMs будет пустым. Насколько понятно, почему?).
У неявных преобразований есть полезные применения, но в большинстве случаев метод типа AsNullableDouble будет значительно более простым и понятным решением, нежели пользовательские преобразования типов, тем более неявные.
На основании того, что кто-то когда-то не правильно, не подумав, что-то применил делать вывод "в большинстве случаев" по-другому "будет значительно более простым и понятным" - оригинально :о)
ОтветитьУдалитьЭтак детей попросту к инструментам, в мастерскую, не допускают, а, допуская, строго контролируют. Но делать вывод из этого о том, что без инструментов "в большинстве случаев" будет лучше - это смело :о)
@Viacheslav: а разве не проще, когда не только компилятору очевидно, какой метод будет исполняться, но и человеку? Наличие неявных преобразований делает сей процесс (выбора метода компилятором) значительно мнее простым и понятным.
ОтветитьУдалитьНу и вообще, это ж, вроде, распространенный совет (он есть у Саттера, Мейерса, Вагнера), а здесь я привел очередное подтверждение того, насколько легко наступить на грабли.
Ну и вообще, есть некоторые языки программирования (тот же F#), в которых все неявные преобразования запрещены, включая преобразования long -> int, string -> object.
Я не предлагаю заходить настолько далеко, но и примеров из реальных приложений (а не библиотек, типа Complex), когда неявные преобразования являются мега-удобными, мне тоже как-то в голову не приходят.
Ну проблема с последним кодом не столько с неявным преобразованием сколько с неочевидным поведением Nullable, всё-таки мне кажется. Будь .Net спланирован немножко аккуратнее может быть бы было что-то вроде:
ОтветитьУдалитьinterface IAdditiveGroup : INullable {
T Add(T b);
}
interface IMultiplicativeGroup : INullable {
T Mul(T b);
}
struct AdditiveNullable : IAdditiveGroup>
where T : IAdditiveGroup
{
// associate default/null with zero
// ...
public T Add(T b)
{
if (!b.HasValue)
return this;
else if (!HasValue)
return b;
else
return new AdditiveNullable(Value.Add(b.Value));
}
}
struct MultiplicativeNullable : IMultiplicativeGroup>
where T : IMultiplicativeGroup
{
// associate default/null with zero
// ...
public T Mul(T b)
{
if (!b.HasValue)
return this;
else if (!HasValue)
return b;
else
return new MultiplicativeNullable(Value.Mul(b.Value));
}
}
struct RingNullable : IAdditiveGroup>, IMultiplicativeGroup>
where T : IAdditiveGroup, IMultiplicativeGroup
{
// ...
public T Add(T b)
// ...
public T Mul(T b)
// ...
}
Yep... C# is even more awful language than C++ for such constructions, I think.