Pull to refresh

Comments 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?
Если брать реальную жизнь: глаза-стебельки, усики и челюсти членистоногих — унаследованы от ног, но использовать ни глаза, ни усики, ни челюсти в качестве ног не получится.
image

Эволюция — неудачный пример наследования с точки зрения ООП. Не надо обманываться похожими словами.

Кроме того, насколько я понял, бивариантность в C# вообще невыразима.
А есть кейсы, когда она нужна? Я может сонный плохо соображаю, но кейс в голову не идёт совсем, с клетками для животных — уж точно.

Чтобы прям нужна была я не встречал. Но бывали случаи, когда была бы полезна. Обычно это различные enshure функции, обеспечивающие определённые инварианты, как в примере с клеткой.

Пока никто его не написал. Можете стать первым, кто это сделает.

Видимо что-то типа такого
#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;
}

Спасибо. Чуть сократил и добавил в статью.

Ну печаль же как сократили. Вы тогда delete уберите хоть чтобы никто таких ошибок не совершал, да и не ясно зачем виртуальные деструкторы убрали — ведь вы же сами Pet и Animal как чистый интерфейс хотите.

Эти деструкторы никак же не иллюстрируют тему статьи.

А будут ли у вас какие-то ссылки на используемые вами определения?


Некоторые считают, что вариантность как-то связана с обобщениями.

Это так и есть.


Однако, во всём повествовании у нас до сих пор не было ни единого обобщения — сплошь конкретные классы. Сделано это было, чтобы показать, что проблемы вариантности никак с обобщениями не связаны.

А это — ваша ошибка.

К слову об иерархиях. Надо внимательно относиться к подобным идеям:


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) // падение в рантайме

С чего бы падение? trySetContent потому и содержит try в названии, что вариант неуспешной операции — ожидаем и подлежит обработке.


А для случая, когда неудача не рассматривается, есть простой оператор присваивания:


const foxCage = new FoxCage
foxCage.content = new Cat // компилятор увидел ошибку

Об это собственно вся статья.

Словосочетание "вид животных" означало конкретный тип животного в построенной вами же иерархии:


image

Это не final классы в java терминологии.

Давайте я вам попробую ответить. Проблема, на которую обращает внимание автор — клетка для лис может содержать функционал, не знаю, причёсывания хвоста лисицы, потому что у всех известных лисиц с хвостами всё нормально. Потом неожиданно найдутся особые бесхвостые лисицы, и мы приехали — непонятно, где их в иерархии описывать.

Дальше сугубо моё понимание ситуации, но по-моему, что Java, что C++ придерживаются LSP в синтаксических проверках, а любое сужающее наследование его нарушает.

Если драма с бесхвостыми лисицами встречается в жизни, то начинают изворачиваться или выносом проблемы в композицию («хвост» из свойства самого класса выносится в геттер, который если что вернёт nullptr), или в множественное наследование — вот, мол, базовый класс «млекопитающие», а вот «хвостатые», и какие-то лисы наследуются от обоих классов, а какие-то от только одного. Заодно второй подход позволяет докинуть в иерархию базовый класс для рептилий и описать, там, крокодилов. Есть, наверное, и другие способы. Важно то, что мы в любом случае не можем добавить сужающий подкласс — для этого приходится модифицировать всю иерархию, чтобы подклассы соответствовали LSP и были только расширяющими.
Важно то, что мы в любом случае не можем добавить сужающий подкласс — для этого приходится модифицировать всю иерархию, чтобы подклассы соответствовали LSP и были только расширяющими.

Угу, я так и пишу — не можем. А вот автор пытается.

Попробуйте не рассматривать LSP как аксиому и вам откроется дивный мир новых идей.

vintage, а в Scala смотрели? Там, насколько я знаю, с этим должно быть все хорошо.
Вот здесь пример с животными.

Выглядит примерно так
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)
}

А можете пример из статьи реализовать, чтобы я в статью добавил?

К сожалению, не являюсь достаточным специалистом в Скале, что бы реализовывать. Боюсь попасть под немилость скалистов )
FlowJS академичный, соответствующий всем законам, но совершенно не практичный. Пришлось год назад переезжать с него на TypeScript. TypeScript наоборот нарушает многие принципы типизации, но нарушает там, где это целесообразно. Фронтенд разработка предъявляет гораздо более высокие требования к гибкости программы, чем любые другие разделы программирования, поэтому в TypeScript реализован Duck Typing, сравнение типов по фактическим полям, а не по иерархии, что абсолютно правильно для фронтенд, с его высокими требованиями к изменчивости. Ребята из Microsoft знают своё дело и гораздо дольше разрабатывают решения для разработки, чем ребята из Facebook, начитавшиеся умных книжек далёких от индустрии профессоров. В случае с TypeScript, Microsoft, на удивление, сделала всё правильно.
В случае с TypeScript, Microsoft, на удивление, сделала всё правильно.

Он не идеален, но выбирать-то не из чего, всё остальное не «где-то рядом», а сильно ущербнее.
ограничение надтипа или ковариантность

Из вашей статьи никак не становится понятным, откуда появляется "ко-" и "контра-", и почему "ограничение типа" — это "-вариантность".


Ковариантность — это, по-русски, "совместное изменение". Т.е. меняем А — и где-то меняется 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#-то?

Многие не задумываются об этом, ведь куда проще перечитать священное писание. Обратите внимание, что озвученные в статье проблемы касаются исключительно типов. От того, что вы замените классы на интерфейсы ничего ровным счётом не поменяется. Вы даже можете выкинуть и классы и интерфейсы, ограничившись лишь структурной типизацией, как в тайпскрипте, и всё равно получить те же проблемы.

Sign up to leave a comment.

Articles