Не удается неявно преобразовать дочерний класс в родительский класс в параметризованном методе типа

#c# #generics #language-design

Вопрос:

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

Я воспроизвел проблему с минимальным примером ниже. Учитывая очень простую структуру класса «родитель-потомок» с использованием только одного универсального метода:

 abstract class AbstractParentClass
{
    public abstract T DoThing<T>() where T : AbstractParentClass;
}

class ConcreteChildClass : AbstractParentClass
{
    public override T DoThing<T>()
    {
        return this;
    }
}
 

Это приведет к ошибке в строке возврата Cannot implicitly convert type 'ConcreteChildClass' to 'T' . Немного странно, учитывая, что T это ограничено экземпляром AbstractParentClass и this тривиально является одним из них, но хорошо, конечно, поэтому мы сделаем это явно:

 public override T DoThing<T>()
{
    return (T) this;
}
 

Теперь сообщение об ошибке гласит Cannot convert type 'ConcreteChildClass' to 'T' . Что? Если T бы не было ограничений, то, конечно, я понимаю, что мы не могли бы этого гарантировать, но с таким наследством, как у нас, конечно, это должен быть простой бросок?

Мы можем приблизиться, явно приведя к родительскому классу:

 public override T DoThing<T>()
{
    return (AbstractParentClass) this;
}
 

Теперь ошибка читается Cannot implicitly convert type 'AbstractParentClass' to 'T'. An explicit conversion exists (are you missing a cast?) . Почему мы не можем неявно преобразовать в точный тип ограничения — какая возможная ситуация может привести к тому, что класс типа ограничения не будет преобразован в… сам по себе? Но, по крайней мере, теперь это решаемо, сообщение об ошибке даже прямо говорит нам, как это сделать:

 public override T DoThing<T>()
{
    return (T)(AbstractParentClass) this;
}
 

И теперь все идет просто отлично. Мы даже можем сделать его немного красивее, если захотим:

 public override T DoThing<T>()
{
    return this as T;
}
 

На протяжении всего этого, если T бы не было ограничений, я, конечно, понимаю, почему эти преобразования были бы невозможны в том виде, в каком они написаны. Но это так, и таким образом, что у компилятора не должно возникнуть никаких проблем с последующим. Неужели компилятор по какой-то причине просто не учитывает ограничения типа для допустимых неявных преобразований? По моему опыту, у всех подобных ситуаций в C# есть очень веская причина, я просто ни за что на свете не могу понять ее для этой.

Если у кого-нибудь есть какое-либо представление о том, почему у компилятора возникают проблемы с этим (по-видимому!) очень простой фрагмент кода, я был бы благодарен за объяснение проблем, связанных с разрешением этих преобразований.

Комментарии:

1. Я был уверен, что ты что-то упустил, но ты абсолютно прав. T должным образом сдерживается ConcreteChildClass . Однако можете ли вы привести реальный пример того, почему вам нужно, чтобы это работало, чтобы мы могли убедиться, что это не проблема X/Y? Использование public abstract AbstractParentClass DoThing(); функционально ничем не отличается от вашего примера, и это работает.

2. Я сам не знаю полного варианта использования в реальном мире, только помог отладить эту проблему с помощью Discord. Однако пропуск параметров типа здесь не был бы вариантом, поскольку возвращаемый тип должен быть определенным подклассом, который DoThing() вызывается, а использование ковариантного возвращаемого типа невозможно, поскольку проект все еще находится в C#7. (Я полагаю, вы могли бы вернуть AbstractParentClass , а затем привести возвращенное значение к определенному подклассу, но это не делает его лучше, если я честен). Меня интересует только причина такого поведения компилятора!

3. А, ладно. Я согласен, что отбрасывание из родительского класса так же плохо, если не хуже, чем ваш подход. Забавный факт, что при использовании интерфейса вместо этого, даже при явном вызове интерфейса в дочернем классе, вы сталкиваетесь с точно такими же ошибками.

4. «возвращаемый тип должен быть конкретным подклассом, который DoThing() вызывается» Нет, это не так… Это должен быть только тип, который наследует AbstractParentClass

5. Я сформулировал это плохо; это не обязательно должно быть связано с языковыми ограничениями, но это должно быть с точки зрения намерений метода, чтобы вы могли использовать методы из ConcreteChildClass возвращаемого объекта, которых нет AbstractParentClass (без ручного приведения возвращаемого объекта, что, как отмечалось выше, просто ужасно с точки зрения удобства использования).

Ответ №1:

Потому что технически вы могли бы сделать:

 class ConcreteChildClass2 : AbstractParentClass
{
    public override T DoThing<T>()
    {
        return null;
    }
}

var ccc = new ConcreteChildClass();
var ccc2 = ccc.DoThing<ConcreteChildClass2>();

 

Даже с теми слепками, которые вы делаете, это взорвется во время выполнения:

 System.InvalidCastException: Unable to cast object of type 'ConcreteChildClass' to type 'ConcreteChildClass2'.
 

Если вместо этого вы это сделаете return this as T , вы получите null в результате вместо исключения приведения.

Другими словами, ограничение не гарантирует, что DoThing возвращает тот же тип, что и определяющий класс, оно гарантирует только, что оно возвращает некоторый тип, который наследуется AbstractParentClass .

Комментарии:

1. Это, конечно, имеет смысл, почему вы не можете просто бросить ConcreteChildClass T прямо! Тогда я просто хотел бы знать, можем ли мы понять, почему он не может неявно привести AbstractParentClass T ни к одному из них (во втором примере), это действительно странная часть для меня…

2. Потому что не каждый AbstractParentClass является собой T . При приведении вы говорите компилятору «доверять», что знаете, что экземпляр является T. Если вы ошибаетесь, вы получите InvalidCastException во время выполнения.

3. Однако, учитывая where T : AbstractParentClass ограничение, должно быть так, что приведение всегда возможно, если я не неправильно понимаю что-то фундаментальное? Без ограничений, очевидно, было бы невозможно сделать такое предположение, да.

4. Посмотрите на мой первый пример. В этом случае this есть а ConcreteChildClass , но T есть ConcreteChildClass2 , поэтому он попытается привести а ConcreteChildClass к а ConcreteChildClass2 , что не удастся.

5. Ах, верно, потому что это было бы попыткой сузить его до другого дочернего класса вместо того, чтобы возвращаться в качестве родительского класса, конечно. Спасибо, я думаю, что наконец-то избавился от этого блока в своей логике, теперь все имеет смысл!

Ответ №2:

«Почему» заключается в том , что, хотя ConcreteChildClass есть AbstractParentClass и T есть AbstractParentClass , это не обязательно гарантирует, что ConcreteChildClass это есть T .

Для аналогии с реальным миром Cat -это а и есть Pet а Dog Pet , но Cat это не а Dog .

Ответ D Стэнли показывает, как вы могли бы вызвать DoThing() значение T , которое было бы неправильным для типа объекта, с которым вы имеете дело.

В зависимости от вашего варианта использования, есть некоторые другие шаблоны, которые вы могли бы попробовать использовать для решения этой проблемы.

Например, вы можете использовать рекурсивный тип объявления перечисления в родительском классе, аналогичный тому, что Java делает для своих перечислений:

 abstract class AbstractParentClass<T> where T : AbstractParentClass<T>
{
    public abstract T DoThing();
}

class ConcreteChildClass : AbstractParentClass<ConcreteChildClass>
{
    public override ConcreteChildClass DoThing()
    {
        return this;
    }
}
 

Это делает его таким, что вы не можете сказать a ConcreteChildClass использовать другой класс в качестве его универсального типа при вызове DoThing(). Вы обеспечиваете, чтобы любой дочерний класс, который правильно следует шаблону, должен возвращать экземпляр правильного типа. Однако это заставляет вас распространять универсальный тип повсюду: вы не можете просто ссылаться на AbstractParentClass без включения какого-либо общего параметра. Вы можете обойти это с помощью нестандартного интерфейса для кода, для использования которого не имеет значения, какой тип реализации он задан. Но это все еще немного неуклюже, потому что (в отличие от перечислений Java) вы не можете обеспечить, чтобы каждая реализация расширяла родительский класс и использовала себя в качестве универсального типа.

Комментарии:

1. Но ты мог бы это сделать class ConcreteChildClass2: AbstractParentClass<ConcreteChildClass> . Не уверен, какой от этого будет вред/польза, но это все равно не гарантирует, что универсальный метод вернет экземпляр определяющего класса.

2. Да, именно это я и имел в виду в своем последнем предложении.

3. Основное преимущество приведенного мною примера будет заключаться в том, что люди, расширяющие базовый класс, скорее всего, будут более тесно связаны с правилами (и добросовестны в их соблюдении), чем люди, ссылающиеся на методы. Например, если вы пишете библиотеку, в которой вы будете предоставлять реализации различных классов, расширяющих родительский класс, и вы ожидаете, что потребители библиотеки будут обращаться DoThing к этим экземплярам, компилятор не помешает вам ошибиться, но, по крайней мере, он может помешать потребителям вашей библиотеки указывать неправильный универсальный тип в своем вызове метода.