четверг, 13 июня 2013 г.

О пользовательских преобразованиях типов

Поскольку мне говорят (точнее пишут), что я немного утомил с философией программирования и стоит браться за ум вспомнить и о технологиях, то я решил не откладывать это дело в долгий ящик, и опубликовать несколько заметок о языке 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 будет значительно более простым и понятным решением, нежели пользовательские преобразования типов, тем более неявные.

Дополнительные ссылки

3 комментария:

  1. На основании того, что кто-то когда-то не правильно, не подумав, что-то применил делать вывод "в большинстве случаев" по-другому "будет значительно более простым и понятным" - оригинально :о)

    Этак детей попросту к инструментам, в мастерскую, не допускают, а, допуская, строго контролируют. Но делать вывод из этого о том, что без инструментов "в большинстве случаев" будет лучше - это смело :о)

    ОтветитьУдалить
  2. @Viacheslav: а разве не проще, когда не только компилятору очевидно, какой метод будет исполняться, но и человеку? Наличие неявных преобразований делает сей процесс (выбора метода компилятором) значительно мнее простым и понятным.

    Ну и вообще, это ж, вроде, распространенный совет (он есть у Саттера, Мейерса, Вагнера), а здесь я привел очередное подтверждение того, насколько легко наступить на грабли.

    Ну и вообще, есть некоторые языки программирования (тот же F#), в которых все неявные преобразования запрещены, включая преобразования long -> int, string -> object.

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

    ОтветитьУдалить
  3. Ну проблема с последним кодом не столько с неявным преобразованием сколько с неочевидным поведением 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.

    ОтветитьУдалить