Чому компілятор не може повністю вирішити виявлення мертвого коду?


192

У компіляторах, якими я користувався на C або Java, є запобігання мертвому коду (попередження, коли рядок ніколи не буде виконуватися). Мій професор каже, що цю проблему ніколи не можна повністю вирішити компіляторами. Мені було цікаво, чому це так. Я не надто знайомий з фактичним кодуванням компіляторів, оскільки це клас, заснований на теорії. Але мені було цікаво, що вони перевіряють (наприклад, можливі рядки введення проти прийнятних входів тощо), і чому це недостатньо.


91
зробіть цикл, поставте код після нього, а потім застосуйте en.wikipedia.org/wiki/Halting_problem
zapl

48
if (isPrime(1234234234332232323423)){callSomething();}чи буде цей код колись щось називати чи ні? Існує багато інших прикладів, коли вирішення того, як функція коли-небудь називається набагато дорожче, ніж просто включення її до програми.
idclev 463035818

33
public static void main(String[] args) {int counterexample = findCollatzConjectureCounterexample(); System.out.println(counterexample);}<- це мертвий код виклику println? Навіть люди не можуть вирішити це!
користувач253751

15
@ tobi303 - не чудовий приклад, дуже просто підрахувати прості числа ... просто не розбивати їх відносно ефективно. Проблема зупинки не в НП, її нерозв'язності.
en_Knight

57
@alephzero та en_Knight - Ви обоє помиляєтесь. isPrime - прекрасний приклад. Ви зробили припущення, що функція перевіряє просте число. Можливо, це число було серійним номером, і воно здійснює пошук у базі даних, щоб побачити, чи є користувачем Amazon Prime member? Причина, яка є чудовим прикладом, полягає в тому, що єдиний спосіб дізнатися, чи є умова постійною чи ні, - це реально виконувати функцію isPrime. Тож тепер це вимагатиме від Укладача також перекладача. Але це все одно не вирішить тих випадків, коли дані є нестабільними.
Данк

Відповіді:


275

Проблема з мертвим кодом пов'язана з проблемою зупинки .

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

Як це стосується мертвого коду?

Проблема зупинки зводиться до проблеми пошуку мертвого коду. Тобто, якщо ви знайдете алгоритм, який може виявити мертвий код у будь-якій програмі, ви можете використовувати цей алгоритм, щоб перевірити, чи зупиниться якась програма. Оскільки це виявилося неможливим, випливає, що написання алгоритму для мертвого коду також неможливо.

Як ви переносите алгоритм для мертвого коду в алгоритм проблеми зупинки?

Просто: ви додаєте рядок коду після закінчення програми, яку хочете перевірити на зупинку. Якщо ваш детектор мертвого коду виявить, що ця лінія мертва, то ви знаєте, що програма не зупиняється. Якщо це не так, то ви знаєте, що ваша програма зупиняється (потрапляє в останній рядок, а потім у ваш доданий рядок коду).


Зазвичай компілятори перевіряють, чи є речі, які можуть бути доведені під час компіляції, мертвими. Наприклад, блоки, які залежать від умов, які можна визначити помилковими під час компіляції. Або будь-яке твердження після return(у тому ж обсязі).

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


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

50
64-бітні процесори @Vality можуть адресувати 2 ^ 64 байти. Приємно шукайте всі 256 ^ (2 ^ 64) станів!
Даніель Вагнер

82
@DanielWagner Це не повинно бути проблемою. Пошук 256^(2^64)станів є O(1), тому виявлення мертвого коду можна здійснити в поліноміальний час.
аебабіс

13
@Leliel, це був сарказм.
Пол Дрейпер

44
@Vality: Більшість сучасних комп'ютерів мають диски, пристрої введення, мережеві комунікації тощо. Будь-який повний аналіз повинен розглянути всі подібні пристрої - включаючи, буквально, Інтернет та все, що підключено до нього. Це не є простежуваною проблемою.
Нат

77

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

Програма C #

using System;
using YourVendor.Compiler;

class Program
{
    static void Main(string[] args)
    {
        string quine_text = @"using System;
using YourVendor.Compiler;

class Program
{{
    static void Main(string[] args)
    {{
        string quine_text = @{0}{1}{0};
        quine_text = string.Format(quine_text, (char)34, quine_text);

        if (YourVendor.Compiler.HasDeadCode(quine_text))
        {{
            System.Console.WriteLine({0}Dead code!{0});
        }}
    }}
}}";
        quine_text = string.Format(quine_text, (char)34, quine_text);

        if (YourVendor.Compiler.HasDeadCode(quine_text))
        {
            System.Console.WriteLine("Dead code!");
        }
    }
}

Якщо YourVendor.Compiler.HasDeadCode(quine_text)повертається false, то лінія System.Console.WriteLn("Dead code!");НЕ буде коли - або виконана, так що ця програма на насправді це є мертвий код, а детектор був неправий.

Але якщо він повернеться true, то рядок System.Console.WriteLn("Dead code!");буде виконана, і оскільки в програмі більше немає коду, мертвого коду взагалі немає, тож знову, детектор помилився.

Отже, у вас його є, детектор мертвого коду, який повертає лише "Існує мертвий код" або "Не існує мертвого коду", іноді повинен давати неправильні відповіді.


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

1
приріст для відповіді Годеля.
Джаред Сміт

@abligh Фу, це був поганий вибір слів. Я фактично не подаю вихідний код детектора мертвого коду до себе, а вихідний код програми, яка його використовує. Звичайно, в якийсь момент, мабуть, доведеться подивитися на власний код, але це його справа.
Joker_vD

65

Якщо проблема зупинки занадто неясна, подумайте про це так.

Візьміть математичну задачу, яка вважається істинною для всіх позитивних цілих чисел n , але не доведено, що вона є правдою для кожного n . Хорошим прикладом може бути припущення Гольдбаха , що будь-яке додатне навіть ціле число, що перевищує два, може бути представлено сумою двох простих чисел. Потім (з відповідною бібліотекою bigint) запустіть цю програму (псевдокод випливає):

 for (BigInt n = 4; ; n+=2) {
     if (!isGoldbachsConjectureTrueFor(n)) {
         print("Conjecture is false for at least one value of n\n");
         exit(0);
     }
 }

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

Тепер, логічно, вищезазначене повинно бути або еквівалентом:

 for (; ;) {
 }

(тобто нескінченна петля) або

print("Conjecture is false for at least one value of n\n");

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

 String target = "f3c5ac5a63d50099f3b5147cabbbd81e89211513a92e3dcd2565d8c7d302ba9c";
 for (BigInt n = 0; n < 2**2048; n++) {
     String s = n.toString();
     if (sha256(s).equals(target)) {
         print("Found SHA value\n");
         exit(0);
     }
 }
 print("Not found SHA value\n");

ми знаємо, що програма або роздрукує "Знайдене значення SHA" або "Не знайдено значення SHA" (бонусні бали, якщо ви можете сказати мені, яке з них є правдивим). Однак, щоб компілятор міг обґрунтовано оптимізувати, що брало б порядку 2 ^ 2048 ітерацій. Насправді це була б велика оптимізація, оскільки я прогнозую, що вищезгадана програма буде (або може) працювати до теплової смерті Всесвіту, а не надрукувати щось без оптимізації.


4
Це найкраща відповідь на сьогодні +1
Жан

2
Те, що робить речі особливо цікавими, - це неоднозначність того, що стандарт C дозволяє чи не дозволяє, якщо говорити про те, що петлі припиняться. Є цінність, що дозволяє компілятору відкладати повільні обчислення, результати яких можуть бути використані або не можуть використовуватися до того моменту, коли їх результати справді будуть потрібні; ця оптимізація може в деяких випадках бути корисною, навіть якщо компілятор не зможе довести, що обчислення закінчуються.
supercat

2
2 ^ 2048 ітерації? Навіть глибока думка здалася б.
Пітер Мортенсен

Він надрукує "Знайдене значення SHA" з дуже високою ймовірністю, навіть якщо ця мета була випадковим рядком з 64 шістнадцяткових цифр. Якщо не sha256повертає байтовий масив і байтові масиви, не порівнюйте рівних рядків у вашій мові.
користувач253751

4
Implementation of isGoldbachsConjectureTrueFor() is left as an exercise for the readerЦе змусило мене посміятися.
biziclop

34

Я не знаю, чи C ++ або Java мають функцію Evalтипу, але багато мов дозволяють вам робити методи виклику по імені . Розглянемо наступний (надуманий) приклад VBA.

Dim methodName As String

If foo Then
    methodName = "Bar"
Else
    methodName = "Qux"
End If

Application.Run(methodName)

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

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

Application.Run("Bar")

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


2
У Java (або C #) це можна зробити за допомогою роздумів. C ++ ви, мабуть, могли зняти деяку неприємність, використовуючи макроси для цього. Не було б красиво, але рідко C ++ є.
Даррель Гофман

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

1
@ArtOfWarfare ми не говоримо про те, як це можна зробити. Очевидно, семантичний аналіз коду можна зробити, щоб знайти цю ситуацію, справа в тому, що компілятор цього не робить . Це, можливо, можливо, але це не так.
RubberDuck

3
@ArtOfWarfare: Якщо ви хочете, щоб вийти з програми, обов'язково. Я вважаю, що препроцесор є частиною компілятора, хоча я технічно це не знаю. У всякому разі, покажчики функцій можуть порушити правило про те, що на функції ніде не посилаються безпосередньо - вони є, як вказівник замість прямого виклику, як делегат у C #. Загалом, компілятору C ++ набагато складніше передбачити, оскільки у нього так багато способів робити побічно. Навіть завдання настільки прості, як «знайти всі посилання», не є тривіальними, оскільки вони можуть ховатися у typedefs, макросах тощо. Не дивно, що не можна легко знайти мертвий код.
Даррель Гофман

1
Вам навіть не потрібні динамічні дзвінки методів для вирішення цієї проблеми. Будь-який публічний метод може бути викликаний ще не написаною функцією, яка буде залежати від вже складеного класу на Java або C # або будь-якої іншої компільованої мови з деяким механізмом динамічного зв’язку. Якщо компілятори усунули їх як "мертвий код", ми б не змогли упакувати попередньо складені бібліотеки для розповсюдження (NuGet, банки, колеса Python з бінарним компонентом).
jpmc26

12

Безумовний мертвий код може виявити та видалити просунуті компілятори.

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

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


5
"Беззастережний мертвий код може виявити та видалити просунуті компілятори." Це не здається ймовірним. Загибель коду може залежати від результату даної функції, і ця функція може вирішити довільні задачі. Таким чином, ваше твердження стверджує, що передові компілятори можуть вирішити довільні проблеми.
Taemyr

6
@Taemyr Тоді не було б відомо, що він безумовно мертвий, а тепер?
JAB

1
@Taemyr Ви, здається, неправильно розумієте слово "безумовне". Якщо загибель коду залежить від результату функції, то це умовний мертвий код. "Умова" є результатом функції. Для того, щоб бути «безумовним» вона повинна була б НЕ залежати від будь - якого результату.
Кієотичний

12

Простий приклад:

int readValueFromPort(const unsigned int portNum);

int x = readValueFromPort(0x100); // just an example, nothing meaningful
if (x < 2)
{
    std::cout << "Hey! X < 2" << std::endl;
}
else
{
    std::cout << "X is too big!" << std::endl;
}

Тепер припустимо, що порт 0x100 призначений для повернення лише 0 або 1. У цьому випадку компілятор не може зрозуміти, що elseблок ніколи не буде виконаний.

Однак у цьому базовому прикладі:

bool boolVal = /*anything boolean*/;

if (boolVal)
{
  // Do A
}
else if (!boolVal)
{
  // Do B
}
else
{
  // Do C
}

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

EDIT

Іноді дані просто недоступні під час компіляції:

// File a.cpp
bool boolMethod();

bool boolVal = boolMethod();

if (boolVal)
{
  // Do A
}
else
{
  // Do B
}

//............
// File b.cpp
bool boolMethod()
{
    return true;
}

Під час компіляції a.cpp компілятор не може знати, що boolMethodзавжди повертається true.


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

1
@Darthfett Це не відповідальність лінкера . Linker не аналізує вміст складеного коду. Лінкер (взагалі кажучи) просто пов'язує методи та глобальні дані, це не хвилює вміст. Однак деякі компілятори мають можливість об'єднати вихідні файли (наприклад, ICC), а потім виконати оптимізацію. У такому випадку справа про EDIT охоплюється, але ця опція вплине на час складання, особливо коли проект великий.
Олексій Лоп.

Ця відповідь здається мені оманливою; ви наводите два приклади, коли це неможливо, оскільки не вся інформація доступна, але чи не слід казати, що це неможливо, навіть якщо інформація є?
Антон Голов

@AntonGolovIt os не завжди правда. У багатьох випадках, коли інформація є, компілятори можуть виявити мертвий код та оптимізувати його.
Олексій Лоп.

@abforce просто блок коду. Це могло бути що-небудь інше. :)
Олексій Лоп.

4

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


4

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

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


3

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

Розглянемо цей простий сценарій:

if (my_func()) {
  am_i_dead();
}

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

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


Якщо ви розглядаєте компілятор як функцію c(), де c(source)=compiled code, а середовище запуску як r(), де r(compiled code)=program output, то для визначення виводу для будь-якого вихідного коду ви повинні обчислити значення r(c(source code)). Якщо обчислювальні c()вимагають знань величини r(c())для будь-якого входу, немає необхідності в окремому r()і c()ви можете просто отримати функцію i()з c()таких , що i(source)=program output.


2

Інші прокоментували проблему зупинки тощо. Вони, як правило, стосуються частин функцій. Однак може бути важко / неможливо дізнатися, використовується навіть цілий тип (клас / тощо) чи ні.

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

Компілятор не може знати, чи будуть завантажуватися такі типи. Їх імена можуть надходити із зовнішніх файлів конфігурації під час виконання.

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


Я не знаю про Java та javascript, але .NET насправді має плагін перерозподілу для такого типу виявлення DI (званий агентом Малдера). Звичайно, він не зможе виявити конфігураційні файли, але він зможе виявити конфіденцію в коді (що набагато популярніше).
Зв'язується

2

Візьміть функцію

void DoSomeAction(int actnumber) 
{
    switch(actnumber) 
    {
        case 1: Action1(); break;
        case 2: Action2(); break;
        case 3: Action3(); break;
    }
}

Чи можете ви довести, що actnumberніколи не буде 2так, що Action2()ніколи не називається ...?


7
Якщо ви можете проаналізувати абонентів функції, то, можливо, ви зможете, так.
abligh

2
@abligh Але компілятор зазвичай не може проаналізувати весь код виклику. У будь-якому разі, навіть якщо це можливо, повний аналіз може зажадати просто моделювання всіх можливих потоків управління, що майже завжди просто неможливо через необхідні ресурси та час. Тож навіть якщо теоретично існує доказ того, що " Action2()ніколи не буде називатися", неможливо довести претензію на практиці - компілятор не може повністю вирішити її . Різниця така, як "існує число X" проти "ми можемо записати число X у десятковій формі". Для деяких X-х останній ніколи не відбудеться, хоча перший є правдою.
CiaPan

Це погана відповідь. інші відповіді доводять, що неможливо знати, чи є actnumber==2. Ця відповідь просто стверджує, що важко, навіть не заявляючи про складність.
MSalters

1

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

Натомість давайте розглянемо:

for (int N = 3;;N++)
  for (int A = 2; A < int.MaxValue; A++)
    for (int B = 2; B < int.MaxValue; B++)
    {
      int Square = Math.Pow(A, N) + Math.Pow(B, N);
      float Test = Math.Sqrt(Square);
      if (Test == Math.Trunc(Test))
        FermatWasWrong();
    }

private void FermatWasWrong()
{
  Press.Announce("Fermat was wrong!");
  Nobel.Claim();
}

(Ігнорувати помилки типу та переповнення) Мертвий код?


2
Остання остання теорема Ферма була доведена в 1994 році. Тому правильна реалізація вашого методу ніколи не запустила б FermatWasWrong. Я підозрюю, що ваша реалізація запустить FermatWasWrong, тому що ви можете досягти межі точності поплавків.
Taemyr

@Taemyr Ага! Ця програма неправильно перевіряє останню теорему Ферма; контрприклад для того, що він робить тест - N = 3, A = 65536, B = 65536 (що дає тест = 0)
користувач253751

@immibis Так, я пропустив, що він переповнить int, перш ніж точність поплавків стане проблемою.
Taemyr

@immibis Зверніть увагу на нижню частину мого повідомлення: ігноруйте помилки типу та переповнення. Я просто брав за основу рішення, що вважав невирішеною проблемою - я знаю, що код не ідеальний. Це проблема, яка ніяк не може бути жорстокою.
Лорен Печтел

-1

Подивіться на цей приклад:

public boolean isEven(int i){

    if(i % 2 == 0)
        return true;
    if(i % 2 == 1)
        return false;
    return false;
}

Компілятор не може знати, що int може бути парним або непарним. Тому компілятор повинен вміти розуміти семантику вашого коду. Як це слід реалізувати? Компілятор не може забезпечити, щоб найнижчий показник ніколи не був виконаний. Тому компілятор не може виявити мертвий код.


1
Гм, справді? Якщо я напишу це в C # + ReSharper, я отримаю пару підказок. Слідом за ними нарешті дає мені код return i%2==0;.
Томас Веллер

10
Ваш приклад занадто простий, щоб бути переконливим. Конкретний випадок i % 2 == 0і i % 2 != 0навіть не вимагає міркування про значення цілочислової модулі постійної (що все ще легко зробити), для цього потрібно лише загальне усунення підвыражения і загальний принцип (навіть канонізація), до якого if (cond) foo; if (!cond) bar;можна спростити if (cond) foo; else bar;. Звичайно, "розуміння семантики" є дуже важкою проблемою, але ця публікація не показує, що вона є, і не показує, що вирішення цієї важкої проблеми необхідне для виявлення мертвих кодів.

5
У вашому прикладі оптимізуючий компілятор помітить загальну піддепресію i % 2та витягне її у тимчасову змінну. Потім він визнає, що два ifтвердження взаємно виключають і можуть бути записані як if(a==0)...else..., а потім помітить, що всі можливі шляхи виконання проходять через перші два returnтвердження, а отже, третє returnтвердження є мертвим кодом. ( Хороший оптимізуючий компілятор ще агресивніший: GCC перетворив мій тестовий код на пару операцій з маніпуляцією бітами).
Марк

1
Цей приклад добре для мене. Він представляє випадок, коли компілятор не знає про деякі фактичні ланцюги. Те ж саме стосується if (availableMemory()<0) then {dead code}.
Маленький Санті

1
@LittleSanti: Насправді GCC виявить, що все, що ви там написали, є мертвим кодом! Це не лише {dead code}частина. GCC виявляє це, доводячи, що неминуче переповнення цілого числа підписане. Таким чином, весь код цієї дуги в графі виконання виконання є мертвим кодом. GCC може навіть видалити умовну гілку, яка веде до цієї дуги.
MSalters
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.