Які відмінності між Generics у C # та Java… та Templates у C ++? [зачинено]


203

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

Отже, які основні відмінності між C ++, C #, Java у генеріках? Плюси / мінуси кожного?

Відповіді:


364

Я додаю свій голос до шуму і задумую, щоб зрозуміти все:

C # Generics дозволяють вам заявити щось подібне.

List<Person> foo = new List<Person>();

і тоді компілятор не дозволить вам внести речі, які не входять Personдо списку.
За лаштунками компілятор C # просто вкладає List<Person>у файл .NET dll, але під час виконання компілятор JIT переходить і створює новий набір коду, як ніби ви написали спеціальний клас списку просто для вміщення людей - щось на зразокListOfPerson .

Користь від цього полягає в тому, що це робить це дійсно швидко. Немає кастингу чи будь-яких інших речей, і оскільки dll містить інформацію про те, що це Перелік Person, інший код, який переглядає його пізніше, використовуючи відображення, може сказати, що він міститьPerson об'єкти (тому ви отримуєте інтелігенцію тощо).

Недоліком цього є те, що старий код C # 1.0 та 1.1 (до того, як вони додали дженерики) не розуміє цих нових List<something>, тому вам доведеться вручну перетворювати речі назад у звичайний старийList щоб взаємодіяти з ними. Це не така велика проблема, оскільки двійковий код C # 2.0 не є сумісним назад. Єдиний раз, коли це станеться, якщо ви модернізуєте старий код C # 1.0 / 1.1 до C # 2.0

Java Generics дозволяє заявити щось подібне.

ArrayList<Person> foo = new ArrayList<Person>();

На поверхні це виглядає так само, і це свого роду. Компілятор також не дозволить вам внести речі, які не входять Personдо списку.

Різниця в тому, що відбувається за лаштунками. На відміну від C #, Java не працює і не створює спеціального ListOfPerson- вона просто використовує звичайний старий, ArrayListякий завжди був у Java. Коли ви виймаєте речі з масиву, звичайний Person p = (Person)foo.get(1);кастинг-танець все-таки потрібно зробити. Компілятор економить ваші натискання клавіш, але швидкість удару / лиття все ще відбувається так, як це було завжди.
Коли люди згадують "Тип стирання", це те, про що вони говорять. Компілятор вставляє касти для вас, а потім 'стирає' той факт, що він мав бути списком Personне простоObject

Перевага такого підходу полягає в тому, що старий код, який не розуміє дженерики, не повинен дбати. Це все ще стосується того самого старого, ArrayListяк завжди. Це важливіше у світі java, оскільки вони хотіли підтримати компіляцію коду за допомогою Java 5 з генерикою, а також запустити його на старих 1.4 або попередніх версіях JVM, з якими мікрософт навмисно вирішив не заважати.

Мінус - це швидкість, про яку я згадував раніше, а також тому, що її немає ListOfPerson що в файлах .class псевдокласу чи чогось подібного, код, який переглядає його згодом (з відображенням, або якщо ви витягнете його з іншої колекції де це було перетворено на Objectтощо), жодним чином не може сказати, що це список, що містить тільки, Personа не будь-який інший список масиву.

Шаблони C ++ дозволяють вам заявити про щось подібне

std::list<Person>* foo = new std::list<Person>();

Це схоже на дженерики C # та Java, і воно буде робити те, що, на вашу думку, повинно робити, але за лаштунками відбуваються різні речі.

Він має найбільш спільне з C # generics у тому, що він будує спеціальний pseudo-classes а не просто викидає інформацію про тип, як це робить java, але це зовсім інший чайник з рибою.

І C #, і Java дають вихід, призначений для віртуальних машин. Якщо ви пишете якийсь код, у якому є Personклас, в обох випадках деяку інформацію про aPerson клас буде потрапляти у файл .dll або .class, і JVM / CLR буде виконувати цю справу.

C ++ виробляє сирий двійковий код x86. Все не є об'єктом, і немає основної віртуальної машини, яка повинна знати проPerson клас. Тут немає боксу чи розпакування, і функції не повинні належати до класів, а то й нічого.

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

У C # та Java система generics повинна знати, які методи доступні для класу, і їй потрібно передати це у віртуальну машину. Єдиний спосіб сказати це - це або жорстке кодування фактичного класу, або використання інтерфейсів. Наприклад:

string addNames<T>( T first, T second ) { return first.Name() + second.Name(); }

Цей код не буде компілюватися в C # або Java, тому що він не знає, що тип Tнасправді забезпечує метод, який називається Name (). Ви повинні сказати це - у C #, як це:

interface IHasName{ string Name(); };
string addNames<T>( T first, T second ) where T : IHasName { .... }

І тоді ви повинні переконатися, що речі, які ви передаєте в addNames, реалізують інтерфейс IHasName тощо. Синтаксис java різний ( <T extends IHasName>), але він страждає від тих же проблем.

"Класичний" випадок цієї проблеми намагається написати функцію, яка це робить

string addNames<T>( T first, T second ) { return first + second; }

Насправді ви не можете написати цей код, оскільки немає способів оголосити інтерфейс із +методом у ньому. Ви провалюєтеся.

C ++ не страждає від жодної з цих проблем. Компілятор не піклується про передачу типів до будь-яких віртуальних машин - якщо обидва об'єкти мають функцію. Якщо вони цього не роблять, це не буде. Простий.

Отже, ось у вас це є :-)


8
Згенерований псевдоклас для типів посилань у C # поділяє таку ж реалізацію, щоб ви не отримали саме ListOfPeople. Ознайомтеся з blogs.msdn.com/ericlippert/archive/2009/07/30/…
Piotr Czapla

4
Ні, ви не можете компілювати код Java 5 за допомогою дженерики, і він може працювати на старих 1.4 VM (принаймні, Sun JDK цього не реалізує. Деякі сторонні інструменти роблять.) Що ви можете зробити, це використовувати раніше скомпільовані 1.4 JAR з 1.5 / 1.6 код.
finnw

4
Я заперечую проти твердження, що ви не можете писати int addNames<T>( T first, T second ) { return first + second; }на C #. Загальний тип може бути обмежений класом замість інтерфейсу, і є спосіб оголосити клас з +оператором у ньому.
Машмагар

4
@AlexanderMalakhov цілком не ідіоматично. Сенс полягав не в тому, щоб вчитись про ідіоми C ++, а проілюструвати, як однаковий вигляд коду по-різному обробляється кожною мовою. Цієї мети було б важче досягти більш різного вигляду коду
Оріон Едвардс

3
@phresnel В принципі я згоден, але якби я написав цей фрагмент в ідіоматичному C ++, це було б набагато менш зрозумілим для розробників C # / Java, а тому (я вважаю) зробив би гіршу роботу з пояснення різниці. Давайте погоджуємося не погоджуватися з цього питання :-)
Orion Edwards

61

C ++ рідко використовує термінологію "generics". Натомість слово "шаблони" використовується і є більш точним. Шаблони описують одну техніку досягнення загального дизайну.

Шаблони C ++ сильно відрізняються від тих, що застосовуються як C #, так і Java з двох основних причин. Перша причина полягає в тому, що шаблони C ++ дозволяють не тільки аргументи типу компіляції, але й аргументи const-значення часу компіляції: шаблони можуть бути надані у вигляді цілих чисел або навіть функцій підписів. Це означає, що ви можете робити кілька цікавих речей під час компіляції, наприклад, обчислення:

template <unsigned int N>
struct product {
    static unsigned int const VALUE = N * product<N - 1>::VALUE;
};

template <>
struct product<1> {
    static unsigned int const VALUE = 1;
};

// Usage:
unsigned int const p5 = product<5>::VALUE;

Цей код також використовує іншу відмінну особливість шаблонів C ++, а саме спеціалізацію шаблонів. Код визначає один шаблон класу, productякий має один аргумент значення. Він також визначає спеціалізацію для цього шаблону, який використовується коли аргумент оцінюється на 1. Це дозволяє мені визначити рекурсію над визначеннями шаблону. Я вважаю, що це вперше виявив Андрій Олександреску .

Спеціалізація шаблонів важлива для C ++, оскільки вона дозволяє структурні відмінності в структурах даних. Шаблони в цілому - це засіб об'єднання інтерфейсу для різних типів. Однак, хоча це бажано, все типи впровадження не можна розглядати однаково. Шаблони C ++ враховують це. Це дуже однакова відмінність, яку OOP робить між інтерфейсом та реалізацією з перевагою віртуальних методів.

Шаблони C ++ є важливими для його парадигми алгоритмічного програмування. Наприклад, майже всі алгоритми для контейнерів визначені як функції, які приймають тип контейнера як тип шаблону та обробляють їх рівномірно. Насправді, це не зовсім правильно: C ++ працює не на контейнерах, а на діапазонах , визначених двома ітераторами, вказуючи на початок і позаду кінця контейнера. Таким чином, весь вміст обчислюється ітераторами: begin <= elements <end.

Використання ітераторів замість контейнерів корисно, оскільки дозволяє оперувати частинами контейнера, а не цілими.

Ще одна відмінна особливість C ++ - це можливість часткової спеціалізації для шаблонів класів. Це дещо пов'язане зі збігом шаблонів аргументів на Haskell та інших функціональних мовах. Наприклад, розглянемо клас, який зберігає елементи:

template <typename T>
class Store {  }; // (1)

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

template <typename T>
class Store<T*> {  }; // (2)

Тепер, коли ми застосовуємо шаблон контейнера для одного типу, використовується відповідне визначення:

Store<int> x; // Uses (1)
Store<int*> y; // Uses (2)
Store<string**> z; // Uses (2), with T = string*.

Я іноді хотів, щоб функція generics у .net могла дозволити речі, крім типів, використовуватись як ключі. Якщо масиви типу значень були частиною Framework (я здивований, вони певним чином не враховують необхідність взаємодії зі старими API, які вбудовують масиви фіксованого розміру в структури), було б корисно оголосити клас, який містив кілька окремих елементів, а потім масив типу значення, розмір якого був загальним параметром. Насправді, найближчим з них може бути об’єкт класу, який містить окремі елементи, а потім також містить посилання на окремий об'єкт, що містить масив.
supercat

@supercat Якщо ви взаємодієте зі застарілим API, ідея полягає у використанні маршалінгу (який можна анотувати за допомогою атрибутів). CLR так чи інакше не має масивів фіксованого розміру, тому наявність аргументів нетипового шаблону тут не допоможе.
Конрад Рудольф

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

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


18

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

Як уже було пояснено, головна відмінність - стирання типу , тобто те, що компілятор Java стирає загальні типи, і вони не опиняються в створеному байтовому коді. Однак питання полягає в тому, навіщо хтось це робити? Це не має сенсу! Або це?

Ну, яка альтернатива? Якщо ви не реалізуєте дженерики на мові, де б ви їх реалізувати? І відповідь: у віртуальній машині. Що порушує зворотну сумісність.

Видалення типу, з іншого боку, дозволяє змішувати загальні клієнти з негенералізованими бібліотеками. Іншими словами: код, скомпільований на Java 5, все ще може бути розгорнутий на Java 1.4.

Однак, Microsoft вирішила зламати сумісність для генериків. Ось чому .NET Generics "кращі", ніж Java Generics.

Звичайно, ВС не ідіоти чи труси. Причиною, по якій вони «зачарувались», було те, що Java була значно старшою та більш розповсюдженою, ніж .NET, коли вони впроваджували дженерики. (Вони були представлені приблизно в один і той же час в обох світах.) Порушення сумісності назад було б величезним болем.

По-іншому: у Java Generics є частиною мови (це означає, що вони застосовуються лише до Java, а не до інших мов), у .NET вони є частиною віртуальної машини (це означає, що вони застосовуються до всіх мов, а не просто C # і Visual Basic.NET).

Порівняйте це з .NET функціями, такими як LINQ, лямбда-вирази, локальні умови змінного типу, анонімні типи та дерева виразів: це все мовні особливості. Ось чому між VB.NET і C # є тонкі відмінності: якби ці функції були частиною VM, вони були б однаковими для всіх мов. Але CLR не змінився: він все ще такий же в .NET 3.5 SP1, як і в .NET 2.0. Ви можете скласти програму C #, яка використовує LINQ разом із компілятором .NET 3.5 і все ще запустити її на .NET 2.0, за умови, що ви не використовуєте жодної бібліотеки .NET 3.5. Це не працюватиме з generics та .NET 1.1, але воно буде працювати з Java та Java 1.4.


3
LINQ - це перш за все функція бібліотеки (хоча C # і VB також додають синтаксичний цукор поряд із ним). Будь-яка мова, націлена на 2.0 CLR, може отримати повне використання LINQ, просто завантаживши збірку System.Core.
Річард Берг

Так, вибачте, я мав би бути більш чітким Wrt. LINQ. Я мав на увазі синтаксис запитів, а не монадичні стандартні оператори запитів, методи розширення LINQ або інтерфейс IQueryable. Очевидно, ви можете використовувати їх з будь-якої мови .NET.
Йорг W Міттаг

1
Я думаю про інший варіант для Java. Навіть Oracle не хочуть порушувати відсталу сумісність, вони все ще можуть зробити якийсь трюк компілятора, щоб уникнути стирання інформації про тип. Наприклад, ArrayList<T>може випромінюватися як новий внутрішньо названий тип із (прихованим) статичним Class<T>полем. Поки нова версія generic lib буде розгорнута з кодом 1,5+ байтів, вона зможе працювати на 1,4-JVM.
Earth Engine

14

Продовження моєї попередньої публікації.

Шаблони - одна з головних причин, чому C ++ не спрацьовує так безглуздо при інтеліссенсі, незалежно від використовуваної IDE. Через спеціалізацію шаблонів IDE ніколи не може бути впевнений у тому, чи існує даний член чи ні. Поміркуйте:

template <typename T>
struct X {
    void foo() { }
};

template <>
struct X<int> { };

typedef int my_int_type;

X<my_int_type> a;
a.|

Тепер курсор знаходиться в зазначеному положенні, і IDE чорт важко сказати в той момент, якщо і що, члени aмають. Для інших мов аналіз буде простим, але для C ++ попередньо потрібно трохи оцінювання.

Це стає гірше. Що робити, якщо my_int_typeбули визначені і всередині шаблону класу? Тепер його тип залежатиме від іншого аргументу типу. І тут навіть компілятори виходять з ладу.

template <typename T>
struct Y {
    typedef T my_type;
};

X<Y<int>::my_type> b;

Трохи задумавшись, програміст зробив би висновок, що цей код той самий, що і вище: Y<int>::my_typeвирішує int, тому bмає бути того ж типу a, що так?

Неправильно. У той момент, коли компілятор намагається вирішити це твердження, він насправді Y<int>::my_typeще не знає ! Тому невідомо, що це тип. Це може бути щось інше, наприклад, функція члена або поле. Це може спричинити неоднозначності (хоча і не в цьому випадку), тому компілятор не вдається. Ми мусимо прямо сказати, що ми посилаємось на ім’я типу:

X<typename Y<int>::my_type> b;

Тепер код складається. Щоб побачити, як виникають неоднозначності в цій ситуації, врахуйте наступний код:

Y<int>::my_type(123);

Це твердження коду є абсолютно дійсним і вказує C ++ виконувати виклик функції Y<int>::my_type. Однак, якщо my_typeце не функція, а скоріше тип, це твердження все-таки буде дійсним і виконуватиме спеціальний склад (формат функції), який часто є викликом конструктора. Компілятор не може сказати, що ми маємо на увазі, тому нам тут доведеться розмежовувати.


2
Я цілком згоден. Однак є надія. Система автодоповнення та компілятор C ++ повинні взаємодіяти дуже тісно. Я майже впевнений, що Visual Studio ніколи не матиме такої функції, але все може статися в Eclipse / CDT або іншому IDE на базі GCC. НАДІЯ! :)
Benoît

6

І Java, і C # представили дженерики після першого випуску мови. Однак існують відмінності в тому, як змінювались основні бібліотеки при введенні дженерики. Робоча мова C # - це не просто магія компілятора, і тому неможливо було узагальнити існуючі класи бібліотеки, не порушуючи зворотної сумісності.

Наприклад, у Java існуючий Framework Collections був повністю узагальнений . У Java немає як загальної, так і застарілої негенеричної версії класів колекцій. У чомусь це набагато зрозуміліше - якщо вам потрібно використовувати колекцію в C #, насправді дуже мало підстав для того, щоб перейти з не загальною версією, але ці застарілі класи залишаються на місці, захаращуючи пейзаж.

Ще одна помітна відмінність - класи Enum на Java та C #. Java Enum має таке дещо покрутливе визначення:

//  java.lang.Enum Definition in Java
public abstract class Enum<E extends Enum<E>> implements Comparable<E>, Serializable {

(див. дуже чітке пояснення Анжеліки Лангер, чому саме це так. По суті, це означає, що Java може надати безпечний доступ з рядка до його значення Enum:

//  Parsing String to Enum in Java
Colour colour = Colour.valueOf("RED");

Порівняйте це з версією C #:

//  Parsing String to Enum in C#
Colour colour = (Colour)Enum.Parse(typeof(Colour), "RED");

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


Навіть дженерики C # не є просто магією компілятора, компілятор може робити магію, щоб узагальнити існуючу бібліотеку. Там немає причин , чому вони повинні перейменувати , ArrayListщоб List<T>і помістити його в новий простір імен. Справа в тому, що якщо у вихідному коді з'явився клас, ArrayList<T>він стане іменем класу, створеним компілятором, в коді IL, тому конфлікту імен не може статися.
Земляний двигун

4

Пізніше на 11 місяців, але я думаю, що це питання готове до деяких матеріалів Java Wildcard.

Це синтаксична особливість Java. Припустимо, у вас є метод:

public <T> void Foo(Collection<T> thing)

І припустимо, вам не потрібно посилатися на тип T в тілі методу. Ви оголошуєте ім’я T, а потім використовуєте його лише один раз, то чому б вам довелося думати ім’я для нього? Натомість ви можете написати:

public void Foo(Collection<?> thing)

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

З подвійними кодами ви нічого не можете зробити, що також не можна зробити з іменованим параметром типу (саме так це завжди робиться в C ++ і C #).


2
Ще запізнення на 11 місяців ... Є деякі функції, які ви можете зробити з подвійними картками Java, які неможливо з названими параметрами типу. Це можна зробити в Java: class Foo<T extends List<?>>і використовувати, Foo<StringList>але в C # ви повинні додати цей параметр додаткового типу: class Foo<T, T2> where T : IList<T2>і використовувати незграбність Foo<StringList, String>.
Р. Мартіньо Фернандес


1

Найбільша скарга - стирання типу. При цьому генеричні дані не застосовуються під час виконання. Ось посилання на деякі документи Sun на цю тему .

Загальна інформація реалізується за допомогою стирання типу: інформація про загальний тип присутня лише під час компіляції, після чого стирається компілятором.


1

Шаблони C ++ насправді набагато потужніші, ніж їх аналоги C # та Java, оскільки вони оцінюються під час компіляції та спеціалізації підтримки. Це дозволяє здійснювати метапрограмування шаблонів і робить компілятор C ++ еквівалентним машині Тьюрінга (тобто під час процесу компіляції ви можете обчислити все, що можна обчислити за допомогою машини Тьюрінга).


1

У Java generics є лише на рівні компілятора, тож ви отримуєте:

a = new ArrayList<String>()
a.getClass() => ArrayList

Зауважте, що тип 'a' - це список масивів, а не список рядків. Отже тип списку бананів дорівнював би () списку мавп.

Так сказати.


1

Схоже, серед інших дуже цікавих пропозицій є ще одна інформація про вдосконалення генерики та порушення зворотної сумісності:

В даний час дженерики реалізовані за допомогою стирання, що означає, що інформація про загальний тип недоступна під час виконання, що робить якийсь код важко записати. Generics були реалізовані таким чином, щоб підтримувати зворотну сумісність зі старими негенеричними кодами. Змінена дженерика зробить інформацію про загальний тип доступною під час виконання, що порушить застарілий не загальний код. Однак, Neal Gafter запропонував зробити типи, які можна повторно застосовувати, лише якщо зазначено, щоб не порушити зворотну сумісність.

у статті Алекса Міллера про пропозиції Java 7


0

NB: У мене недостатньо балів для коментарів, тому сміливо перенесіть це як коментар на відповідну відповідь.

Всупереч поширеній думці, яку я ніколи не розумію, звідки вона походить, .net реалізував справжні дженерики, не порушуючи відсталої сумісності, і вони витратили на це явні зусилля. Вам не доведеться змінювати ваш негенеричний код .net 1.0 на дженерик лише для використання в .net 2.0. І загальний, і негенеричний списки все ще доступні в .Net Framework 2.0 навіть до 4.0, саме з нічого, окрім причини зворотної сумісності. Тому старі коди, які досі використовували не загальний ArrayList, все одно будуть працювати, і використовувати той же клас ArrayList, що і раніше. Сумісність із зворотним кодом завжди підтримується з 1.0 до теперішнього часу ... Тож навіть у .net 4.0, ви все одно маєте можливість використовувати будь-який негенеричний клас від 1.0 BCL, якщо ви вирішите це зробити.

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


Це не той тип відсталої сумісності, про який люди говорять. Ідея полягає у зворотній сумісності для виконання : Код, написаний за допомогою дженерики в .NET 2.0, не може бути запущений на старих версіях .NET Framework / CLR. Так само, якби Java вводила "справжні" дженерики, новіший Java-код не міг би працювати на старих JVM (оскільки він вимагає порушення змін до байтового коду).
tzaman

Це .net, а не дженерики. Завжди вимагає перекомпіляції для націлювання на конкретну версію CLR. Є сумісність байт-кодів, є сумісність з кодом. А також, я спеціально відповідав на необхідність перетворення старого коду, який використовував старий Список, щоб використовувати новий Список генериків, що зовсім не відповідає дійсності.
Sheepy

1
Я думаю, що люди говорять про сумісність вперед . Тобто код .net 2.0 для запуску в .net 1.1, який буде порушений, оскільки 1.1 час роботи нічого не знає про 2.0 "псевдоклас". Чи не повинно бути тоді, що "Java не реалізує справжнє загальне, тому що вони хочуть підтримувати сумісність вперед"? (а не назад)
Sheepy

Питання сумісності тонкі. Я не думаю, що проблема полягала в тому, що додавання "справжніх" дженериків до Java вплине на будь-які програми, що використовують старіші версії Java, а скоріше, щоб код, який використовував "нові вдосконалені" дженерики, важко обмінявся б такими об'єктами зі старими кодами, які нічого не знав про нові типи. Припустимо, наприклад, що програма має ArrayList<Foo>те, що вона хоче перейти до більш старого методу, який повинен бути заповнений ArrayListз екземплярами Foo. Якщо ArrayList<foo>це не так ArrayList, як це зробити?
supercat
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.