Комментарии 62
К Java возможность переключения вариантности была добавлена довольно поздно и лишь для обобщённых параметров, которые сами-то появились сравнительно недавно.
Это вы про версию 1.5, которая вышла 30 сентября 2004? Не, я понимаю, в исторической перспективе 15 лет это ничто, но с другой стороны, даже самые древние версии, что я вижу сегодня — это версии с поддержкой generics.
На тот момент ей было уже 15лет. И насколько я знаю, до сих пор не избавились от ковариантности изменяемых массивов.
Object[] arr= new Integer[]{...}
так и хочется сказать: «Ну ты вот тут ты сам напросился на ошибку». То есть, это разработчик виноват в первую очередь. Я понимаю в тоже время, что пример сознательно синтетический, чтобы продемонстрировать проблему. Поэтому вопрос задал бы так: можно ли подобрать другой пример, где такая операция с массивом была бы хоть какими-то потребностями оправдана?
Во-первых, хорошо бы чтобы за такими вещами следил компилятор, а не программист. Во-вторых, эта ситуация возникает каждый раз при создании массива обобщённого типа, спасибо type erasure за это.
Затем, что так можно, и синтаксически они не отличаются от нормальных.
Если нужен практический пример, то вот:
Проверить массив на отсутствие null.
Object[] arr= new Integer[]{1, null, 3};
Object[] nn = Stream.of(arr).filter(o -> o != null).toArray();
System.out.println(Arrays.toString(nn));
Проверили, и?
Хорошо, аргумент какого типа принимает функция, решающая данную задачу?
Дискуссия началась с того, что вы спросили зачем в Яве сделали ковариантные массивы.
Если конкретный тип элемента массива не важен, то выглядит странным писать несколько функций, отличающихся только типом аргумента.
Вообще-то, я не совсем это спрашивал. Приведенный пример ковариантности (при всем понимании того, что это специально так написано, чтобы продемонстрировать поведение) — он чисто синтетический. Так в реальной жизни писать просто нельзя. С тем же успехом можно написать (Integer)obj, в произвольном месте программы, а потом жаловаться, что оно упало.
Ковариантность массивов — это фича. Ее такой могли сделать по массе причин, одна из которых очевидная совместимость. Это не является undefined behavior, например — их поведение вполне документированное. Если оно не устраивает — вы их просто не используете. Как например, я практически никогда не использую сегодня synchronized, предпочитая им более высокоуровневые вещи. А массивам — коллекции.
Я поэтому и попросил привести практический пример задачи, которую решают таким способом (в идеале — которую нельзя при этом решить другим способом).
>выглядит странным писать несколько функций, отличающихся только типом аргумента.
Естественно. А коллекции вам в данном случае чем конкретно не подошли? Я могу придумать свой пример — но проблема в том, что меня ковариантность массивов вообще не напрягает (и как показывают комментарии, я тут не один такой). Вот и хочется посмотреть на примеры от тех, кто жалуется на это — в конце концов, моя практика это лишь моя практика, и она ограничена по определению.
Она нарушает LSP.
С ковариантными массивами получается такая ситуация, что у вас вроде как есть массив Foo[]
— но положить в него new Foo()
вы почему-то не можете.
Если у нас есть класс A (не виртуальный, а вполне реально используемый в коде) и отнаследованный от него класс B, то если мы заменим все использования класса A на B, ничего не должно измениться в работе программы. Ведь класс B всего лишь расширяет функционал класса A. Если эта проверка работает, то поздравляю: ваша программа соответствует принципу подстановки Лисков!
А как же использование OVERRIDE?
Если брать реальную жизнь: глаза-стебельки, усики и челюсти членистоногих — унаследованы от ног, но использовать ни глаза, ни усики, ни челюсти в качестве ног не получится.
Кроме того, насколько я понял, бивариантность в C# вообще невыразима.А есть кейсы, когда она нужна? Я может сонный плохо соображаю, но кейс в голову не идёт совсем, с клетками для животных — уж точно.
А где же пример на C++?
Пока никто его не написал. Можете стать первым, кто это сделает.
#include <iostream>
#include <typeinfo>
#include <type_traits>
class IAnimal
{
public:
virtual ~IAnimal() = 0;
};
IAnimal::~IAnimal() {}
class IPet:
public IAnimal
{
public:
virtual ~IPet() = 0;
};
IPet::~IPet() {}
class Cat:
public IPet {};
class Dog:
public IPet {};
class Fox:
public IAnimal {};
template<class T>
class Cage
{
public:
T *content;
const char* content_name() const { return typeid(T).name(); }
Cage(T *content_ = nullptr):
content(content_)
{}
~Cage() { delete content; }
private:
Cage(const Cage&) = delete;
};
template<class T,
class = std::enable_if_t<std::is_base_of<IPet, T>::value>/**/>
void touchPet(const Cage<T> &cage) {
std::cout << cage.content_name() << std::endl;
}
template<class T,
class = std::enable_if_t<std::is_base_of<T, IPet>::value>/**/>
void pushPet(Cage<T> &cage) {
delete cage.content;
cage.content = new Dog();
}
void replacePet(Cage<IPet> &cage) {
touchPet(cage);
pushPet(cage);
}
template<class T,
class = std::enable_if_t<std::is_base_of<T, IPet>::value>/**/>
void enshurePet(Cage<T> &cage) {
if(dynamic_cast<IPet*>(cage.content)) return;
pushPet(cage);
}
int main(void)
{
Cage<IAnimal> animalCage{new Fox()};
Cage<IPet> petCage{new Cat()};
Cage<Cat> catCage{new Cat()};
Cage<Dog> dogCage{new Dog()};
Cage<Fox> foxCage{new Fox()};
touchPet( animalCage ); // forbid :-)
touchPet( petCage ); // allow :-)
touchPet( catCage ); // allow :-)
touchPet( dogCage ); // allow :-)
touchPet( foxCage ); // forbid :-)
pushPet( animalCage ); // allow :-)
pushPet( petCage ); // allow :-)
pushPet( catCage ); // forbid :-)
pushPet( dogCage ); // forbid :-)
pushPet( foxCage ); // forbid :-)
replacePet( animalCage ); // forbid :-)
replacePet( petCage ); // allow :-)
replacePet( catCage ); // forbid :-)
replacePet( dogCage ); // forbid :-)
replacePet( foxCage ); // forbid :-)
return 0;
}
А будут ли у вас какие-то ссылки на используемые вами определения?
Некоторые считают, что вариантность как-то связана с обобщениями.
Это так и есть.
Однако, во всём повествовании у нас до сих пор не было ни единого обобщения — сплошь конкретные классы. Сделано это было, чтобы показать, что проблемы вариантности никак с обобщениями не связаны.
А это — ваша ошибка.
К слову об иерархиях. Надо внимательно относиться к подобным идеям:
class AnimalCage { content : Animal }
class FoxCage extends AnimalCage { content : Fox }
Это некорректное наследование. Вы его написали, потому что считаете, что "клетка для лис" — это разновидность "клетки для животных". Но проблема в том, что вы в разных местах вашей иерархии под "клеткой для животных" понимаете разные вещи!
Ваш класс AnimalCage — это клетка для любых животных. То есть это такая клетка, в которую можно поместить хоть кота, хоть лису, хоть слона. FoxCage же — это клетка для одного конкретного вида животных. Разумеется, FoxCage не является AnimalCage.
Правильная иерархия могла бы выглядеть как-то так:
abstract class SomeAnimalCage {
readonly content : Animal;
readonly animalType : typeof Animal;
abstract trySetContent(animal: Animal) : boolean;
}
class AnyAnimalCage extends SomeAnimalCage {
content : Animal;
readonly animalType = Animal;
trySetContent(animal: Animal) {
this.content = animal;
return true;
}
}
class FoxCage extends SomeAnimalCage {
content : Fox;
readonly animalType = Fox;
trySetContent(animal: Animal) {
if (animal instanceof Fox) {
this.content = animal;
return true;
}
return false;
}
}
И да, если внимательно относиться к иерархиям — то решение "проблемы" с LSP возможно только одно:
Запретить объектам при наследовании сужать типы своих полей.
Но слона в клетку для кота добавить всё равно не получится.
Специально для вас добавил ремарку:
Многие считают, что отношение надтип-подтип определяется не исходя из упомянутых ранее способов сужения и расширения, а возможностью подставить подтип в любое место использования надтипа. По всей видимости, причина этого заблуждения именно в LSP. Однако, давайте прочитаем определение этого принципа внимательно, обратив внимание, что первично, а что вторично:
Функции, которые используют базовый тип, должны иметь возможность использовать подтипы базового типа, не зная об этом и не нарушая корректность работы программы.
А тот код, что вы написали потерял все статические проверки, в результате чего можно написать код, помещающий слона в клетку для котов. Упадёт это только в рантайме на проде.
Не понял к чему ваша ремарка.
А тот код, что вы написали потерял все статические проверки, в результате чего можно написать код, помещающий слона в клетку для котов. Упадёт это только в рантайме на проде.
Каким, интересно, образом?
const foxCage = new FoxCage
foxCage.trySetContent(new Cat) // падение в рантайме
FoxCage же — это клетка для одного конкретного вида животных.
Нет, есть много видов лис.
Словосочетание "вид животных" означало конкретный тип животного в построенной вами же иерархии:
Это не final классы в java терминологии.
А разница?
Дальше сугубо моё понимание ситуации, но по-моему, что Java, что C++ придерживаются LSP в синтаксических проверках, а любое сужающее наследование его нарушает.
Если драма с бесхвостыми лисицами встречается в жизни, то начинают изворачиваться или выносом проблемы в композицию («хвост» из свойства самого класса выносится в геттер, который если что вернёт nullptr), или в множественное наследование — вот, мол, базовый класс «млекопитающие», а вот «хвостатые», и какие-то лисы наследуются от обоих классов, а какие-то от только одного. Заодно второй подход позволяет докинуть в иерархию базовый класс для рептилий и описать, там, крокодилов. Есть, наверное, и другие способы. Важно то, что мы в любом случае не можем добавить сужающий подкласс — для этого приходится модифицировать всю иерархию, чтобы подклассы соответствовали LSP и были только расширяющими.
Важно то, что мы в любом случае не можем добавить сужающий подкласс — для этого приходится модифицировать всю иерархию, чтобы подклассы соответствовали LSP и были только расширяющими.
Угу, я так и пишу — не можем. А вот автор пытается.
Боюсь я не знаком с этим языком. Можете сделать пример?
Выглядит примерно так
class Foo[+A] // A covariant class
class Bar[-A] // A contravariant class
class Baz[A] // An invariant class
Плюс там есть ограничения варинтности до определенного типа (не уверен в правильности определения. В оригинале type bounds):
trait Node[+B] {
def prepend[U >: B](elem: U): Node[U]
}
case class ListNode[+B](h: B, t: Node[B]) extends Node[B] {
def prepend[U >: B](elem: U): ListNode[U] = ListNode(elem, this)
def head: B = h
def tail: Node[B] = t
}
case class Nil[+B]() extends Node[B] {
def prepend[U >: B](elem: U): ListNode[U] = ListNode(elem, this)
}
ограничение надтипа или ковариантность
Из вашей статьи никак не становится понятным, откуда появляется "ко-" и "контра-", и почему "ограничение типа" — это "-вариантность".
Ковариантность — это, по-русски, "совместное изменение". Т.е. меняем А — и где-то меняется B, сходным образом. Мы меняем тип параметра функции — но что ещё меняется и в чём проявляется "совместность"?
Вот если бы вы указали, что сама функция имеет комплексный тип (например, Pet -> PetCage
), и что если заменить какой-то из этих параметров на подтип, то функция тоже поменяет свой тип, и окажется либо подтипом, либо надтипом оригинальной функции, то было бы понятно, что именно тут изменяется совместным (или обратным) образом.
Variance — это вариативность. Мы декларируем один тип параметра, а передавать можно разные типы аргументов. Ковариантность — в соответствии с отношением надтип-подтип. Контравариантность — против этого отношения. Но это не очень удачные термины, так как кажется, что в результате их объединения получится объединение возможных типов, а оказывается — пересечение.
Пост — бриллиант, без шуток.
Правильно ли понимание, что в 99% случаев (если не всегда), ковариантность = in-семантика и контравариантность = out-семантика? Т.е. если мы только читаем (в статье упоминалось ФП), то никакой контравариантности не нужно даже в принципе.
Если так, то подход C# (возможно, измененный, т.к. generics тут и правда ни при чем), где вместо новых терминов используются простые и понятные in- и out-префиксы, можно считать предпочтительным. Зачем вообще вводить новые термины, когда есть старые-добрые интуитивные?
Зачем вообще вводить новые термины, когда есть старые-добрые интуитивные?
Маркетологи такое очень любят.
Только наоборот: контравариантность — это in, а ковариантность — это out. Любое другое понимание нарушает LSP, а значит приводит к трудноуловимым багам.
А вот насчёт "generics тут и правда ни при чем" вы не правы. Они тут "причём" именно потому, что новые термины были введены для описания отношений между иерархиями типов, а не просто для замены чего-то интуитивного.
Это не новые термины, к сожалению, а устоявшиеся. Но да, это in, out и inout семантика. Правда термины in/out легко перепутать. То, что для одного in — то для другого out. А вот если рассуждать в терминах ограничений типов, то понять где что становится куда проще.
С изменяемыми же всё сложнее, так как следующие две ситуации являются взаимоисключающими для принципа LSP:
У класса A есть подкласс B, где поле B::foo является подтипом A::foo.
У класса A есть метод, который может изменить поле A::foo.
Соответственно, остаётся лишь три пути:
Запретить объектам при наследовании сужать типы своих полей. Но тогда в клетку для кота вы сможете засовывать и слона.
Руководствоваться не LSP, а вариантностью каждого параметра каждой функции в отдельности. Но тогда придётся много думать и объяснять компилятору где какие ограничения на типы.
Плюнуть на всё и уйти в монастырь функциональное программирование, где все объекты неизменяемые, а значит параметры их принимающие ковариантны объявленному типу.
Долго не мог понять, что же мне не нравится в этой статье и почему за 15+ лет разработки у меня не возникало таких проблем. В этой ситуации есть 4й и 5й абсолютно канонические пути.
4. Перечитать принципы SOLID и воспользоваться последним принципом «Зависимость на Абстракциях. Нет зависимости на что-то конкретное». Мы никогда не завязываемся на конкретный класс в иерархии, а завязываемся на интерфейс и все проблемы с конкретной реализацией или местом класса в иерархии волшебным образом исчезают.
5. Разобраться с LSP и тем, зачем он вообще введён и какие плюсы даёт. А главное преимущество, что мы можем обращаться с потомками так же, как с родителем. То есть мы можем из A::foo спокойно менять B::foo как если бы это был A::foo, если A::foo и B::foo соответствуют LSP.
Таким образом изначальная задача, с классами-потомками которые имеют свойства, так же имеющие иерархию, прекрасно решается. Однако похоже вы хотели срезать углы, которые не срезаются, а именно:
животное.функция(клетка); // можно
кошка.функция(клетка); // ошибка
так делать не стоит и ваши коллеги покрывали бы вас трёхэтажным матом. Большинство языков такое запрещают и я не знаю, что нашло на создателей C#, видимо писали ТЗ утром в понедельник. Так как кошка потомок животного, методы принимающие клетку в животном должны принимать клетку и в кошке, это и есть LSP, нельзя написать такой код и соблюсти LSP, просто по определению LSP. Зачем это нужно? Для слабой связанности в программе, которая приводит к модульности, снижению когнитивной сложности кода, возможности добавлять больше функционала без взрыва сложности. Если хотите, это можно назвать обратной совместимостью между потомком и родителем. В этом как-бы смысл наследования, если вам это не нужно — не используйте наследование, просто создайте несколько независимых классов и добавьте нужное поведение из общих модулей. В каноническом подходе, если есть функция, которая умеет помещать абстрактное животное в абстрактную клетку — вам не нужно реализовывать такую-же функцию в классах-потомках, если инструкции не отличаются. Даже если отличаются, можно воспользоваться паттерном Стратегия, и оставить реализацию в базовом классе общей для всех потомков. А проверку соответствия животного клетке имеет смысл вынести из класса или добавить аггрегацией как один из шагов Стратегии. Валидация — это не задача системы классов, задача системы классов — переиспользование функционала. Если нарушить LSP, как раз переиспользование сильно пострадает, а что вы получите взамен, более компактный синтаксис? В ваших проектах оно правда того стоит?
Большинство языков такое запрещают и я не знаю, что нашло на создателей C#, видимо писали ТЗ утром в понедельник.
А что именно нашло на создателей C#-то?
Многие не задумываются об этом, ведь куда проще перечитать священное писание. Обратите внимание, что озвученные в статье проблемы касаются исключительно типов. От того, что вы замените классы на интерфейсы ничего ровным счётом не поменяется. Вы даже можете выкинуть и классы и интерфейсы, ограничившись лишь структурной типизацией, как в тайпскрипте, и всё равно получить те же проблемы.
Разобрал отдельно именно тему LSP: https://habr.com/ru/post/521258/
Теория программирования: Вариантность