Странность системы типов: Enumerable.Cast <int>()

Рассматривать:

enum Foo
{
    Bar,
    Quux,
}

void Main()
{
    var enumValues = new[] { Foo.Bar, Foo.Quux, };
    Console.WriteLine(enumValues.GetType());         // output: Foo[]
    Console.WriteLine(enumValues.First().GetType()); // output: Foo

    var intValues = enumValues.Cast<int>();
    Console.WriteLine(intValues.GetType());         // output: Foo[] ???
    Console.WriteLine(intValues.First().GetType()); // output: Int32

    Console.WriteLine(ReferenceEquals(enumValues, intValues)); // true

    var intValuesArray = intValues.ToArray();
    Console.WriteLine(intValuesArray.GetType());         // output: Int32[]
    Console.WriteLine(intValuesArray.First().GetType()); // output: Int32

    Console.WriteLine(ReferenceEquals(intValues, intValuesArray)); // false
}

Обратите внимание на третий Console.WriteLine - я ожидаю, что он напечатает тип, к которому преобразуется массив (Int32[]), но вместо этого он печатает исходный тип (Foo[])! И ReferenceEquals подтверждает, что действительно, первый вызов Cast<int> по сути является no-op.

Поэтому я заглянул в источник Enumerable.Cast и обнаружил следующее:

public static IEnumerable<TResult> Cast<TResult>(this IEnumerable source) 
{
    IEnumerable<TResult> typedSource = source as IEnumerable<TResult>;
    if (typedSource != null) return typedSource;
    if (source == null) throw Error.ArgumentNull("source");
    return CastIterator<TResult>(source);
}

Для наших намерений и целей единственное, что имеет значение, - это первые две строки, потому что они единственные, кого вызывают. Это означает, что строка:

var intValues = enumValues.Cast<int>();

эффективно переводится на:

var intValues = ((IEnumerable)enumValues) as IEnumerable<int>;

Однако удаление приведения к неуниверсальному IEnumerable вызывает ошибку компилятора:

var intValues = enumValues as IEnumerable<int>; // error

Я ломал голову над тем, почему это так, и я думаю, что это связано с тем фактом, что Array реализует неуниверсальный IEnumerable и что в С# есть все виды специальных оболочек для массивов, но я, честно говоря, Точно сказать не могу. Может кто-нибудь объяснить мне, что здесь происходит и почему?

Ответ 1

Я думаю, что это связано с тем, что Array реализует неуниверсальный IEnumerable и что в С# есть все виды специальных оболочек для массивов.

Да, ты прав. Точнее, это связано с дисперсией массива. Дисперсия массива - это ослабление системы типов, которая произошла в .NET1.0, что было проблематично, но позволяло обходить некоторые сложные случаи. Вот пример:

string[] first = {"a", "b", "c"};
object[] second = first;
string[] third = (string[])second;
Console.WriteLine(third[0]); // Prints "a"

Это довольно слабо, потому что это не мешает нам делать:

string[] first = {"a", "b", "c"};
object[] second = first;
Uri[] third = (Uri[])second; // InvalidCastException

И снова есть худшие случаи.

Это менее полезно (если бы они когда-либо были оправданы, о чем некоторые могли бы спорить), теперь у нас есть дженерики (начиная с .NET2.0 и С# 2 и далее), чем раньше, когда это позволило нам преодолеть некоторые ограничения, не навязывая нам дженерики.

Правила позволяют нам делать неявное приведение к базам ссылочных типов (например, string[] к object[]) явное приведение к производным ссылочным типам (например, object[] к string[]) и явное приведение из Array или IEnumerable к любому типу массива а также (это важная часть) Array и IEnumerable ссылки на массивы примитивных типов или перечислений могут быть преобразованы в массивы примитивных типов перечислений одинакового размера (перечисления int, uint и int -based имеют одинаковый размер),

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

Практический эффект этого, который сбил меня с enumValues.Cast<StringComparison>().ToArray() в прошлом, заключается в том, что вы должны попробовать enumValues.Cast<StringComparison>().ToArray() или enumValues.Cast<StringComparison>().ToList(). Они потерпят неудачу с ArrayTypeMismatchException даже если enumValues.Cast<StringComparison>().Skip(0).ToArray() будет успешным, потому что, как и Cast<TResult>() с использованием отмеченной оптимизации, ToArray<TSource>() и ToList<TSource>() использует оптимизацию вызова ICollection<T>.CopyTo() внутри, и для массивов, которые терпят неудачу с разновидностью дисперсии, связанной здесь.

В .NET Core были ослаблены ограничения на CopyTo() с массивами, что означает, что этот код выполняется лучше, чем бросание, но я забыл, в какой версии это изменение было внесено.

Ответ 2

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

Я ожидаю, что он напечатает тип, в который преобразуется массив Int32[], но вместо этого он печатает оригинальный тип Foo[] !

Чего вы ожидали? Контракт Cast<int> заключается в том, что возвращаемый объект может использоваться в любом контексте, который ожидает IEnumerable<int>, и вы это получили. Это все, что вы должны были ожидать; остальное - детали реализации.

Теперь я допускаю, что тот факт, что Foo[] может использоваться как IEnumerable<int> является странным, но помните, что Foo - это просто очень тонкая оболочка для int. Размер Foo совпадает с размером int, содержимое Foo совпадает с содержимым int, и поэтому CLR в своей мудрости отвечает "да", когда его спрашивают "это Foo[] пригодно для использования?" как IEnumerable<int>? "

Но как насчет этого?

enumValues as IEnumerable<int> вызывает ошибку компилятора

Это звучит как противоречие, не так ли?

Проблема в том, что правила С# и правила CLR не совпадают в этой ситуации.

  • В CLR говорится, что " Foo[] может использоваться как int[], а также как uint[] и...".
  • Анализатор типа С# является более строгим. Он не использует все правила слабой ковариации CLR. Анализатор типа С# позволит использовать string[] в качестве object[] и позволит использовать IEnumerable<string> в качестве IEnumerable<object> но не позволит использовать Foo[] в качестве int[] или IEnumerable<int> и так далее. С# допускает только ковариацию, когда различные типы являются ссылочными типами. CLR допускает ковариацию, когда различные типы являются ссылочными типами или перечислениями типа int, uint или int -sized.

Компилятор С# "знает", что преобразование из Foo[] в IEnumerable<int> не может быть успешно выполнено в системе типов С#, и поэтому он выдает ошибку компилятора; преобразование в С# должно быть возможным, чтобы быть законным. Тот факт, что это возможно в более мягкой системе типов CLR, не учитывается компилятором.

Вставляя приведение к object или IEnumerable или что-то еще, вы говорите компилятору С# прекратить использование правил С# и начинаете позволять среде выполнения выяснить это. Удаляя приведение, вы говорите, что хотите, чтобы компилятор С# вынес свое решение, и он это делает.

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

  • С# может соответствовать правилам CLR и разрешать ковариантные преобразования среди целочисленных типов.
  • С# может генерировать оператор as чтобы он реализовывал правила С# во время выполнения; в основном, это должно было бы обнаружить законные в CLR, но незаконные в С# преобразования и запретить их, делая все такие преобразования медленнее. Более того, это потребовало бы от вашего сценария перехода к медленному пути Cast<T> выделяющему память, вместо быстрого пути с сохранением ссылок.
  • С# может быть непоследовательным и жить с непоследовательностью.

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

Затем дело доходит до первого и третьего вариантов, а команда разработчиков С# 1.0 выбрала третий. (Помните, что команда разработчиков С# 1.0 не знала, что они будут добавлять дженерики в С# 2.0 или непостоянство дженериков в С# 4.0.) Для команды разработчиков С# 1.0 вопрос заключался в том, enumValues as int[] ли enumValues as int[] быть законными или нет, и они решили нет. Затем это проектное решение было принято снова для С# 2.0 и С# 4.0.

Есть много принципиальных аргументов с обеих сторон, но на практике эта ситуация почти никогда не возникает в коде реального мира, и несоответствие почти никогда не имеет значения, поэтому самый дешевый выбор состоит в том, чтобы просто жить с нечетным фактом, что (IEnumerable<int>)(object)enumValues допустим, но (IEnumerable<int>)enumValues нет.

Подробнее об этом см. Мою статью 2009 года на эту тему.

https://blogs.msdn.microsoft.com/ericlippert/2009/09/24/why-is-covariance-of-value-typed-arrays-inconsistent/

и этот связанный вопрос:

Почему мой массив С# теряет информацию о знаке типа при приведении к объекту?

Ответ 3

Предположим, у вас есть Car класса и производная машина: Volvo.

Car car = new Volvo();
var type = car.GetType();

Несмотря на то, что вы бросили Volvo на Car, объект по-прежнему остается Volvo. Если вы спросите его тип, вы получите, что это typeof(Volvo).

Enumerable.Cast<TResult> каждый элемент входной последовательности в TResult, но они по-прежнему являются вашими исходными объектами. Следовательно, ваша последовательность Foos все еще является Foos, даже после того, как вы приведете их к целым числам.

Дополнение после комментариев

Несколько человек отметили, что это не относится к IEnumerable.Cast. Я думал, что это будет странно, но давайте попробуем:

class Car
{
    public string LicensePlate {get; set;}
    public Color Color {get; set;}
    ...
}
class Volvo : Car
{
    public string OriginalCountry => "Sverige";
}

использование:

var volvos = new List<Volvo>() {new Volvo(), new Volvo(), new Volvo()};
var cars = volvos.Cast<Car>();
foreach (var car in cars) Console.WriteLine(car.GetType());

Результат:

CarTest.Volvo
CarTest.Volvo
CarTest.Volvo

Вывод: Enumerable.Cast не меняет тип объекта