Чи Список <Dog> підклас Список <Animal>? Чому дженерики Java не є неявно поліморфними?


770

Я трохи розгублений щодо того, як Java-дженерики обробляють спадщину / поліморфізм.

Припустимо таку ієрархію -

Тварина (батьків)

Собака - Кішка (діти)

Отже, припустимо, у мене є метод doSomething(List<Animal> animals). За всіма правилами успадкування та поліморфізму, я б припустив, що a List<Dog> є a, List<Animal>а a List<Cat> є a List<Animal>- і тому будь-який з них може бути переданий цьому методу. Не так. Якщо я хочу досягти такої поведінки, я повинен чітко сказати методу, щоб прийняти список будь-якого підкласу Animal, сказавши doSomething(List<? extends Animal> animals).

Я розумію, що це поведінка Java. Моє питання чому ? Чому поліморфізм взагалі неявний, але коли мова йде про дженерики, то він повинен бути уточнений?


17
І зовсім не пов'язане з граматикою питання, яке мене зараз турбує - чи повинен мій заголовок "чому не дженерики Java" чи "чому не дженерики Java" ?? Чи є "дженерики" множиною через s або однини, тому що це одна сутність?
froadie

25
дженерики, як це зроблено на Яві, є дуже поганою формою параметричного поліморфізму. Не вкладайте в них занадто багато віри (як я раніше), тому що одного разу ви сильно наберете їхні жалюгідні обмеження: Хірург розширює придатний <скальпель>, придатний <шпонд> КАБОМ ! Чи має НЕ обчислювати [TM]. Існує ваше обмеження на дженерику Java. Будь-який OOA / OOD можна добре перекласти на Java (а ІМ можна зробити дуже непогано за допомогою інтерфейсів Java), але генеричні файли просто не скорочують його. Вони чудово підходять для "колекцій" і процедурного програмування, які сказали (саме так і роблять більшість Java-програмістів так ...).
SyntaxT3rr0r

8
Суперклас списку <Dog> - це не список <Animal>, а список <?> (Тобто список невідомого типу). Generics стирає інформацію про тип у складеному коді. Це робиться для того, щоб код, який використовує дженерики (java 5 і вище), сумісний з більш ранніми версіями Java без дженерики.
rai.skumar

4
Пов'язане питання ТА
Aniket Thakur

9
@froadie, оскільки, здавалося, ніхто не відповідає ... це безумовно повинно бути "чому не дженерики Java ...". Інше питання полягає в тому, що "generic" - це власне прикметник, і тому "generics" має на увазі відмінений іменник множини, змінений на "generic". Можна сказати, «що функція є загальною», але це було б більш громіздко, ніж казати «ця функція є загальною». Однак трохи громіздко сказати, що "Java має загальні функції та класи", а не просто "Java має дженерики". Як хтось, хто написав свою магістерську роботу про прикметники, я думаю, ви натрапили на дуже цікаве запитання!
Дантістон

Відповіді:


916

Ні, List<Dog>це НЕList<Animal> . Поміркуйте, що ви можете зробити з List<Animal>- ви можете додати до нього будь-яку тварину ... включаючи кішку. Тепер ви можете логічно додати кота до посліду цуценят? Абсолютно ні.

// Illegal code - because otherwise life would be Bad
List<Dog> dogs = new ArrayList<Dog>(); // ArrayList implements List
List<Animal> animals = dogs; // Awooga awooga
animals.add(new Cat());
Dog dog = dogs.get(0); // This should be safe, right?

Раптом у вас дуже заплутана кішка.

Тепер ви не можете додати знак Cata, List<? extends Animal>оскільки ви не знаєте, що це List<Cat>. Ви можете отримати значення і знати, що воно буде Animal, але ви не можете додавати довільних тварин. Зворотний бік справедливий List<? super Animal>- у цьому випадку ви можете Animalбезпечно додати його, але ви нічого не знаєте про те, що може бути отримано з нього, оскільки це може бути а List<Object>.


50
Цікаво, що кожен список собак є дійсно список тварин, так само , як інтуїція підказує нам. Справа в тому, що не кожен список тварин - це список собак, отже, проблема мутації списку шляхом додавання кота є проблемою.
Інго

66
@Ingo: Ні, не дуже: ви можете додати кота до списку тварин, але ви не можете додати кішку до списку собак. Список собак - це лише список тварин, якщо розглянути його в сенсі лише для читання.
Джон Скіт

12
@JonSkeet - Звичайно, але хто вимагає, щоб скласти новий список від кота та списку собак насправді змінює список собак? Це довільне рішення щодо реалізації на Java. Той, що суперечить логіці та інтуїції.
Інго

7
@Ingo: Я б не використовував це "звичайно" для початку. Якщо у вас є список, в якому вгорі написано "Готелі, до яких ми можемо хотіти", а потім хтось додав до нього басейн, чи вважаєте ви це дійсним? Ні - це список готелів, який не є списком будівель. І це не так, як я навіть сказав "Список собак - це не список тварин" - я вкладаю його в кодовому плані , шрифтом коду. Я дійсно не думаю, що тут немає ніякої неоднозначності. Використання підкласу все одно було б невірним - мова йде про сумісність присвоєння, а не підкласифікацію.
Джон Скіт

14
@ruakh: Проблема полягає в тому, що ви потім плануєте на час виконання те, що може бути заблоковано під час компіляції. І я б заперечував, що коваріація масиву була помилкою в дизайні.
Джон Скіт

84

Те, що ви шукаєте, називається параметрами коваріантного типу . Це означає, що якщо один тип об'єкта може бути заміщений іншим у методі (наприклад, Animalйого можна замінити Dog), те саме стосується виразів із використанням цих об'єктів (тому їх List<Animal>можна замінити List<Dog>). Проблема полягає в тому, що коваріація не є безпечною для змінних списків взагалі. Припустимо, у вас є List<Dog>, і він використовується як List<Animal>. Що відбувається, коли ви намагаєтесь додати Кішку до цього, List<Animal>що є насправді List<Dog>? Автоматичне забезпечення параметрів типу коваріантним розбиває систему типу.

Було б корисно додати синтаксис, щоб дозволити параметри типу визначати як коваріант, що дозволяє уникнути ? extends Fooдекларацій методу, але це додає додаткової складності.


44

Причина a List<Dog>не a List<Animal>, полягає в тому, що, наприклад, ви можете вставити Catв a List<Animal>, а не в List<Dog>... ви можете використовувати символи, щоб зробити дженеріки більш розширюваною, де це можливо; наприклад, читання з а List<Dog>є подібним до читання з List<Animal>- але не письмового.

У " Generics" на мові Java та в розділі "Генеріки" з навчальних посібників на Java є дуже хороше, поглиблене пояснення того, чому деякі речі є поліморфними чи дозволеними для дженериків.


36

Точка, на яку я думаю, слід додати те, що згадують інші відповіді, це поки

List<Dog>не-а List<Animal> на Java

це також правда, що

Список собак - це список тварин англійською мовою (ну, під розумним тлумаченням)

Останнє речення - те, як працює інтуїція ОП - що цілком справедливо, - це останнє речення. Однак якщо ми застосуємо цю інтуїцію, ми отримаємо мову, яка не є Java-esque у своїй системі типів: припустимо, наша мова дозволяє додати кота до нашого списку собак. Що це означатиме? Це означало б, що список перестає бути списком собак і залишається лише списком тварин. І список ссавців, і список чотириногих.

Якщо говорити інакше: A List<Dog>на Java не означає "список собак" англійською мовою, це означає "список, де можуть бути собаки, і більше нічого".

Загалом, інтуїція ОП орієнтується на мову, якою операції з об'єктами можуть змінювати свій тип , а точніше, тип (и) об'єкта є (динамічною) функцією його значення.


Так, людська мова більш нечітка. Але все ж, щойно ви додасте до списку собак іншу тварину, це все ще список тварин, але вже не список собак. Різниця в тому, що людина, з нечіткою логікою, зазвичай не має жодних проблем з цим усвідомлювати.
Власек

Оскільки хтось, хто вважає постійне порівняння масивів ще більш заплутаним, ця відповідь прибила мене. Моєю проблемою була мовна інтуїція.
FLonLon

32

Я б сказав, що вся суть в Generics полягає в тому, що це не дозволяє. Розглянемо ситуацію з масивами, які дозволяють давати тип коваріації:

  Object[] objects = new String[10];
  objects[0] = Boolean.FALSE;

Цей код чудово компілює, але видає помилку виконання ( java.lang.ArrayStoreException: java.lang.Booleanу другому рядку). Це не безпечно. Сенс Generics полягає в тому, щоб додати безпеку типу часу компіляції, інакше ви можете просто дотримуватися звичайного класу без дженерики.

Тепер є час , коли ви повинні бути більш гнучкими , і що це те , що ? super Classі ? extends Classдля. Перший - це коли вам потрібно вставити тип Collection(наприклад), а останній - коли вам потрібно читати з нього, безпечним способом. Але єдиний спосіб зробити обидва одночасно - це мати певний тип.


13
Можливо, коваріація масиву - це помилка дизайну мови. Зауважте, що через стирання типу однакова поведінка технічно неможлива для загального збору.
Майкл Боргвардт

" Я б сказав, що вся суть в Generics полягає в тому, що це не дозволяє ". Ви ніколи не можете бути впевнені: Системи типів Java та Scala не потрібні: екзистенціальна криза нульових покажчиків (представлена ​​на OOPSLA 2016) (оскільки виправлено, здається)
Девід Тонхофер

15

Для розуміння проблеми корисно порівняти масиви.

List<Dog>не є підкласом List<Animal>.
Але Dog[] є підкласом Animal[].

Масиви піддаються повторенню та коваріанту .
Повторна інформація означає, що інформація про їх тип є повністю доступною під час виконання.
Тому масиви забезпечують безпеку типу виконання, але не забезпечують безпеку типу компіляції.

    // All compiles but throws ArrayStoreException at runtime at last line
    Dog[] dogs = new Dog[10];
    Animal[] animals = dogs; // compiles
    animals[0] = new Cat(); // throws ArrayStoreException at runtime

Це навпаки для дженериків:
Дженерики стерта і інваріантної .
Тому дженерики не можуть забезпечити безпеку типу виконання, але вони забезпечують безпеку типу компіляції.
У наведеному нижче коді, якщо дженерики були коваріантними, можна буде забруднити купу за рівнем 3.

    List<Dog> dogs = new ArrayList<>();
    List<Animal> animals = dogs; // compile-time error, otherwise heap pollution
    animals.add(new Cat());

2
Можна стверджувати, що саме через це масиви на Яві розбиті ,
leonbloy

Масиви, що є коваріантними, є "особливістю" компілятора.
Крістік

6

Надані тут відповіді мене повністю не переконали. Тому замість цього я роблю ще один приклад.

public void passOn(Consumer<Animal> consumer, Supplier<Animal> supplier) {
    consumer.accept(supplier.get());
}

звучить чудово, чи не так? Але ви можете передавати лише Consumers і Suppliers для Animals. Якщо у вас є Mammalспоживач, але Duckпостачальник, вони не повинні відповідати, хоча обидва є тваринами. Щоб заборонити це, були додані додаткові обмеження.

Замість вищезазначеного ми маємо визначити зв’язки між типами, які ми використовуємо.

E. g.,

public <A extends Animal> void passOn(Consumer<A> consumer, Supplier<? extends A> supplier) {
    consumer.accept(supplier.get());
}

гарантує, що ми можемо використовувати лише постачальника, який надає нам правильний тип об’єкта для споживача.

ОТО, ми могли б також зробити

public <A extends Animal> void passOn(Consumer<? super A> consumer, Supplier<A> supplier) {
    consumer.accept(supplier.get());
}

куди ми йдемо іншим шляхом: ми визначаємо тип Supplierі обмежуємо його введення в Consumer.

Ми навіть можемо зробити

public <A extends Animal> void passOn(Consumer<? super A> consumer, Supplier<? extends A> supplier) {
    consumer.accept(supplier.get());
}

де, маючи інтуїтивні відносини Life-> Animal-> Mammal-> Dog, і Catт.д., ми могли б навіть поставити Mammalв Lifeспоживача, але не Stringв Lifeспоживача.


1
Серед 4 версій №2, мабуть, неправильний. наприклад, ми не можемо назвати це, (Consumer<Runnable>, Supplier<Dog>)поки Dogє підтипомAnimal & Runnable
ZhongYu

5

Основа логіки такої поведінки полягає в тому, щоб Genericsслідувати механізму стирання типу. Тож під час виконання у вас немає можливості визначити тип на collectionвідміну від того, arraysде немає такого процесу стирання. Тож повертаючись до вашого питання ...

Тож припустимо, що існує метод, наведений нижче:

add(List<Animal>){
    //You can add List<Dog or List<Cat> and this will compile as per rules of polymorphism
}

Тепер, якщо java дозволяє абоненту додати Список типу Animal до цього методу, ви можете додати неправильну річ у колекцію, і під час виконання вона також запуститься через стирання типу. Хоча у випадку масивів ви отримаєте виняток часу виконання для таких сценаріїв ...

Таким чином, по суті така поведінка реалізована так, що не можна додати неправильну річ до колекції. Тепер я вважаю, що стирання типу існує, щоб надати сумісність із застарілою Java без дженериків ....


4

Підтипізація є інваріантною для параметризованих типів. Навіть жорсткий клас Dogє підтипом Animal, параметризований тип List<Dog>не є підтипом List<Animal>. Навпаки, коваріантне підтипування використовується масивами, тому тип масиву Dog[]є підтипом Animal[].

Інваріантне підтипування гарантує, що обмеження типу, що застосовуються Java, не порушуються. Розглянемо наступний код, поданий @Jon Skeet:

List<Dog> dogs = new ArrayList<Dog>(1);
List<Animal> animals = dogs;
animals.add(new Cat()); // compile-time error
Dog dog = dogs.get(0);

Як заявив @Jon Skeet, цей код є незаконним, оскільки в іншому випадку він може порушити типові обмеження, повернувши кота, коли собака очікувала.

Доцільно порівнювати вищезазначене з аналогічним кодом для масивів.

Dog[] dogs = new Dog[1];
Object[] animals = dogs;
animals[0] = new Cat(); // run-time error
Dog dog = dogs[0];

Код законний. Однак, викидає виняток у магазині масивів . Масив несе свій тип під час виконання таким чином JVM може забезпечити безпеку типу коваріантного підтипу.

Щоб зрозуміти це далі, давайте подивимося на байт-код, згенерований javapкласом нижче:

import java.util.ArrayList;
import java.util.List;

public class Demonstration {
    public void normal() {
        List normal = new ArrayList(1);
        normal.add("lorem ipsum");
    }

    public void parameterized() {
        List<String> parameterized = new ArrayList<>(1);
        parameterized.add("lorem ipsum");
    }
}

За допомогою команди javap -c Demonstrationвідображається наступний байт-код Java:

Compiled from "Demonstration.java"
public class Demonstration {
  public Demonstration();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public void normal();
    Code:
       0: new           #2                  // class java/util/ArrayList
       3: dup
       4: iconst_1
       5: invokespecial #3                  // Method java/util/ArrayList."<init>":(I)V
       8: astore_1
       9: aload_1
      10: ldc           #4                  // String lorem ipsum
      12: invokeinterface #5,  2            // InterfaceMethod java/util/List.add:(Ljava/lang/Object;)Z
      17: pop
      18: return

  public void parameterized();
    Code:
       0: new           #2                  // class java/util/ArrayList
       3: dup
       4: iconst_1
       5: invokespecial #3                  // Method java/util/ArrayList."<init>":(I)V
       8: astore_1
       9: aload_1
      10: ldc           #4                  // String lorem ipsum
      12: invokeinterface #5,  2            // InterfaceMethod java/util/List.add:(Ljava/lang/Object;)Z
      17: pop
      18: return
}

Зауважте, що перекладений код тестів методу ідентичний. Компілятор замінив кожен параметризований тип його стиранням . Ця властивість має вирішальне значення, оскільки вона не порушувала зворотної сумісності.

На закінчення, безпека під час запуску неможлива для параметризованих типів, оскільки компілятор замінює кожен параметризований тип його стиранням. Це робить параметризовані типи не що інше, як синтаксичний цукор.


3

Насправді ви можете використовувати інтерфейс для досягнення того, що ви хочете.

public interface Animal {
    String getName();
    String getVoice();
}
public class Dog implements Animal{
    @Override 
    String getName(){return "Dog";}
    @Override
    String getVoice(){return "woof!";}

}

потім ви можете використовувати колекції за допомогою

List <Animal> animalGroup = new ArrayList<Animal>();
animalGroup.add(new Dog());

1

Якщо ви впевнені, що елементи списку є підкласами цього супер-типу, ви можете передати цей список за допомогою такого підходу:

(List<Animal>) (List<?>) dogs

Це корисно, коли ви хочете передати список конструктору або повторити його


2
Це створить більше проблем, ніж насправді вирішує
Феррібіг

Якщо ви спробуєте додати кішку до списку, переконайтесь, що це створить проблеми, але для певного циклу я вважаю, що це єдиний не багатослівний відповідь.
sagits

1

Відповідь , а також інші правильні відповіді. Я збираюся додати до цих відповідей рішення, яке, на мою думку, буде корисним. Я думаю, що це часто виникає в програмуванні. Варто зазначити, що для колекцій (списки, набори тощо) головне питання - це додавання до колекції. Саме тут все ламається. Навіть видалення добре.

У більшості випадків ми можемо Collection<? extends T>скоріше використовувати , Collection<T>і це має бути першим вибором. Однак я знаходжу випадки, коли це зробити непросто. Це на дискусії щодо того, чи завжди це найкраще робити. Я представляю тут клас DownCastCollection, який може прийняти перетворення a Collection<? extends T>в Collection<T>(ми можемо визначити подібні класи для List, Set, NavigableSet, ..), які використовуватимуться при використанні стандартного підходу, дуже незручно. Нижче наводиться приклад того, як його використовувати (ми також могли б використати Collection<? extends Object>в цьому випадку, але я просто проілюструю це за допомогою DownCastCollection.

/**Could use Collection<? extends Object> and that is the better choice. 
* But I am doing this to illustrate how to use DownCastCollection. **/

public static void print(Collection<Object> col){  
    for(Object obj : col){
    System.out.println(obj);
    }
}
public static void main(String[] args){
  ArrayList<String> list = new ArrayList<>();
  list.addAll(Arrays.asList("a","b","c"));
  print(new DownCastCollection<Object>(list));
}

Тепер клас:

import java.util.AbstractCollection;
import java.util.Collection;
import java.util.Iterator;
import java.util.NoSuchElementException;

public class DownCastCollection<E> extends AbstractCollection<E> implements Collection<E> {
private Collection<? extends E> delegate;

public DownCastCollection(Collection<? extends E> delegate) {
    super();
    this.delegate = delegate;
}

@Override
public int size() {
    return delegate ==null ? 0 : delegate.size();
}

@Override
public boolean isEmpty() {
    return delegate==null || delegate.isEmpty();
}

@Override
public boolean contains(Object o) {
    if(isEmpty()) return false;
    return delegate.contains(o);
}
private class MyIterator implements Iterator<E>{
    Iterator<? extends E> delegateIterator;

    protected MyIterator() {
        super();
        this.delegateIterator = delegate == null ? null :delegate.iterator();
    }

    @Override
    public boolean hasNext() {
        return delegateIterator != null && delegateIterator.hasNext();
    }

    @Override
    public  E next() {
        if(!hasNext()) throw new NoSuchElementException("The iterator is empty");
        return delegateIterator.next();
    }

    @Override
    public void remove() {
        delegateIterator.remove();

    }

}
@Override
public Iterator<E> iterator() {
    return new MyIterator();
}



@Override
public boolean add(E e) {
    throw new UnsupportedOperationException();
}

@Override
public boolean remove(Object o) {
    if(delegate == null) return false;
    return delegate.remove(o);
}

@Override
public boolean containsAll(Collection<?> c) {
    if(delegate==null) return false;
    return delegate.containsAll(c);
}

@Override
public boolean addAll(Collection<? extends E> c) {
    throw new UnsupportedOperationException();
}

@Override
public boolean removeAll(Collection<?> c) {
    if(delegate == null) return false;
    return delegate.removeAll(c);
}

@Override
public boolean retainAll(Collection<?> c) {
    if(delegate == null) return false;
    return delegate.retainAll(c);
}

@Override
public void clear() {
    if(delegate == null) return;
        delegate.clear();

}

}


Це гарна ідея, настільки, що вона вже існує в Java SE. ; )Collections.unmodifiableCollection
Radiodef

1
Правильно, але колекцію, яку я визначаю, можна змінити.
дан b

Так, його можна змінити. Collection<? extends E>Хоча вже керує такою поведінкою правильно, якщо тільки ви не використовуєте її таким чином, що не є безпечним для типу (наприклад, передача її на щось інше). Єдина перевага, яку я бачу, - це те, що ви викликаєте addоперацію, вона викидає виняток, навіть якщо ви її зробили.
Власек

0

Давайте візьмемо приклад з підручника JavaSE

public abstract class Shape {
    public abstract void draw(Canvas c);
}

public class Circle extends Shape {
    private int x, y, radius;
    public void draw(Canvas c) {
        ...
    }
}

public class Rectangle extends Shape {
    private int x, y, width, height;
    public void draw(Canvas c) {
        ...
    }
}

Тож чому список собак (кола) не слід вважати неявним переліком тварин (фігур) через цю ситуацію:

// drawAll method call
drawAll(circleList);


public void drawAll(List<Shape> shapes) {
   shapes.add(new Rectangle());    
}

Тож у Java "архітекторів" було два варіанти вирішення цієї проблеми:

  1. не вважайте, що підтип неявно є супертипом, і дайте помилку компіляції, як це відбувається зараз

  2. вважати підтип його суперпертипом і обмежуватись при компіляції методу "add" (тому в методі drawAll, якщо буде передано список кіл, підтип форми, компілятор повинен виявити це і обмежити вас помилкою компіляції у виконанні що).

З очевидних причин, що обрали перший шлях.


0

Ми також повинні врахувати, як компілятор загрожує родовим класам: у "інстанціює" інший тип кожного разу, коли ми заповнюємо загальні аргументи.

Таким чином, у нас є ListOfAnimal, ListOfDogі ListOfCatт.д., які є різними класами, які в кінцевому підсумку створюються компілятором, коли ми вказуємо загальні аргументи. І це плоска ієрархія (насправді відносно Listце зовсім не ієрархія).

Іншим аргументом, чому коваріація не має сенсу у випадку загальних класів, є той факт, що в основі всі класи однакові - це Listвипадки. Спеціалізація a Listшляхом заповнення загального аргументу не розширює клас, він просто змушує його працювати для цього конкретного загального аргументу.


0

Проблема була чітко визначена. Але є рішення; зробити йоЗотеЬИпд родовим:

<T extends Animal> void doSomething<List<T> animals) {
}

тепер ви можете зателефонувати в doSomething за допомогою списку <Dog> або списку <Cat> або списку <Animal>.


0

іншим рішенням є створення нового списку

List<Dog> dogs = new ArrayList<Dog>(); 
List<Animal> animals = new ArrayList<Animal>(dogs);
animals.add(new Cat());

0

Далі до відповіді Джон Скіт, який використовує цей приклад коду:

// Illegal code - because otherwise life would be Bad
List<Dog> dogs = new ArrayList<Dog>(); // ArrayList implements List
List<Animal> animals = dogs; // Awooga awooga
animals.add(new Cat());
Dog dog = dogs.get(0); // This should be safe, right?

На найглибшому рівні, проблема тут полягає в тому , що dogsі animalsчастка посилання. Це означає, що одним із способів зробити цю роботу було б скопіювати весь список, який би порушив рівність еталону:

// This code is fine
List<Dog> dogs = new ArrayList<Dog>();
dogs.add(new Dog());
List<Animal> animals = new ArrayList<>(dogs); // Copy list
animals.add(new Cat());
Dog dog = dogs.get(0);   // This is fine now, because it does not return the Cat

Після виклику List<Animal> animals = new ArrayList<>(dogs);, ви не можете згодом безпосередньо призначити animalsабо dogsабо cats:

// These are both illegal
dogs = animals;
cats = animals;

тому ви не можете помістити неправильний підтип Animalу список, оскільки немає неправильного підтипу - до нього ? extends Animalможе бути доданий будь-який об’єкт підтипу animals.

Очевидно, це змінює семантику, оскільки списки animalsі dogsбільше не надаються спільним доступом, тому додавання до одного списку не додає до іншого (що саме те, що ви хочете, щоб уникнути проблеми, яка Catможе бути додана до списку, який є лише повинен містити Dogпредмети). Також копіювання всього списку може бути неефективним. Однак це вирішує проблему еквівалентності типу, порушуючи еталонну рівність.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.