Мечта о декларативном программировании [закрыто]


26

Почему мечта о декларативном программировании не осуществилась? Какие конкретные препятствия мешают? Для простого примера, почему я не могу сказать

sort(A) is defined by sort(A) in perm(A) && asc(sort(A))

и автоматически получить алгоритм сортировки из него. permозначает перестановки и ascозначает возрастание.


4
Кстати ваш конкретный пример рода уже доступны: gkoberger.github.io/stacksort
Den

3
Вы слышали о Прологе? Просто посмотрите на «Программирование набора ответов». Существует множество систем, основанных на логике по умолчанию.
Шлингель

16
Ну, на этот вопрос легко ответить. Попытка реализовать такую ​​систему . Что помешало вам сделать это успешно? Хорошие шансы, что то, что остановило тебя, остановило всех остальных.
Эрик Липперт

4
Я склонен полагать, что этот вопрос заслуживает большего доверия, чем он получает. Когда вы смотрите на это с первого взгляда, вы можете подумать: « Ну, это просто! Вы должны запрограммировать всю эту логику, и компьютеры просто не такие умные. ... Но потом вы вернетесь и еще раз взгляните на этот вопрос, и еще раз подумаете: ну, да, это просто, и вам действительно нужно программировать всю эту логику - и компьютеры не обязательно являются самыми острыми инструментами в сарае, правда - но это объяснение гораздо глубже, чем то, что просто лежит на поверхности.
Panzercrisis

3
Ваше описание алгоритма сортировки является декларативным, да, но это, черт возьми, неэффективно. Существуют n!перестановки последовательности, и в худшем случае вашему алгоритму придется попробовать их все, чтобы найти отсортированную. Факторное время примерно так же плохо, как алгоритм обработки последовательности.
Бенджамин Ходжсон

Ответы:


8

Есть несколько очень хороших ответов. Я постараюсь внести свой вклад в обсуждение.

На тему декларативного, логического программирования в Прологе есть замечательная книга Ричарда О'Кифа «Ремесло Пролога» . Речь идет о написании эффективных программ с использованием языка программирования, который позволяет писать очень неэффективные программы. В этой книге, обсуждая эффективные реализации нескольких алгоритмов (в главе «Методы программирования»), автор придерживается следующего подхода:

  • определить проблему на английском языке
  • написать рабочее решение, как можно более декларативное; обычно это в значительной степени означает именно то, что у вас есть в вашем вопросе, просто исправьте Пролог
  • оттуда, предпримите шаги, чтобы усовершенствовать реализацию, чтобы сделать это быстрее

Самое поучительное (для меня) наблюдение, которое я смог сделать, работая над этим:

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

Другими словами, при реализации решения мы использовали как можно больше наших знаний о проблеме. Для сравнения:

Найти перестановку списка таким образом, чтобы все элементы были в порядке возрастания

чтобы:

Объединение двух отсортированных списков приведет к сортировке списка. Поскольку могут быть отсортированные списки, используйте их в качестве отправной точки вместо списков длиной 1.

Немного в стороне: определение, подобное тому, которое вы дали, привлекательно, потому что оно очень общее. Однако я не могу избежать ощущения, что он целенаправленно игнорирует тот факт, что перестановки являются, ну, в общем, комбинаторной проблемой. Это то, что мы уже знаем ! Это не критика, а просто наблюдение.

Что касается реального вопроса: как двигаться вперед? Ну, один из способов - предоставить как можно больше знаний о проблеме, которую мы объявляем компьютеру.

Лучшая попытка, которую я знаю, чтобы действительно решить эту проблему, представлена ​​в книгах, написанных в соавторстве с Александром Степановым «Элементы программирования» и «От математики к общему программированию» . К сожалению, я не в состоянии подвести итог (или даже полностью понять) все в этих книгах. Однако подход заключается в том, чтобы определить эффективные (или даже оптимальные) библиотечные алгоритмы и структуры данных при условии, что все соответствующие свойства входных данных известны заранее. Окончательный результат:

  • Каждое четко определенное преобразование является уточнением уже существующих ограничений (известных свойств);
  • Мы позволяем компьютеру решать, какое преобразование является оптимальным, исходя из существующих ограничений.

Что касается того, почему этого еще не произошло, ну, информатика - это действительно молодая область, и мы все еще не можем по-настоящему оценить новизну большинства из них.

PS

Чтобы дать вам представление о том, что я имею в виду под «уточнением реализации»: возьмем, к примеру, простую задачу получения последнего элемента списка в Прологе. Каноническое декларативное решение состоит в следующем:

last(List, Last) :-
    append(_, [Last], List).

Здесь декларативное значение append/3:

List1AndList2это объединение List1иList2

Так как во втором аргументе append/3у нас есть список только с одним элементом, а первый аргумент игнорируется (подчеркивание), мы получаем разделение исходного списка, который отбрасывает начало списка ( List1в контексте append/3) и требует, чтобы задняя часть ( List2в контексте append/3) действительно является списком только с одним элементом: так, это последний элемент.

Однако фактическая реализация, предоставленная SWI-Prolog , гласит:

last([X|Xs], Last) :-
    last_(Xs, X, Last).

last_([], Last, Last).
last_([X|Xs], _, Last) :-
    last_(Xs, X, Last).

Это все еще красиво декларативно. Читайте сверху вниз, там написано:

Последний элемент списка имеет смысл только для списка хотя бы из одного элемента. Таким образом, последний элемент для пары хвоста и заголовка списка: заголовок, когда хвост пуст, или последний из непустого хвоста.

Причина, по которой обеспечивается эта реализация, заключается в том, чтобы обойти практические вопросы, связанные с моделью исполнения Prolog. В идеале не должно иметь значения, какая реализация используется. Точно так же мы могли бы сказать:

last(List, Last) :-
    reverse(List, [Last|_]).

Последний элемент списка является первым элементом обращенного списка.

Если вы хотите получить неокончательные дискуссии о том, что является хорошим, декларативным Прологом, просто просмотрите некоторые вопросы и ответы в теге Пролог на переполнении стека .


2
+1 за демонстрацию того, как декларативный дизайн может перейти от простой абстракции к более конкретной реализации через итеративный процесс проектирования.
Брюс

1
@ Борис Это хороший ответ. Эта книга сидит на моей книжной полке. Вероятно, пришло время открыть это.
davidk01

1
@ davidk01 Одна из лучших книг там. Предполагается, что вы довольно хорошо знакомы с Прологом и программированием в целом, но подход к программированию является прагматичным и очень тщательным.
XXX

2
@ Борис Я знаю, что пример не сложный, но производительность итеративного процесса проектирования - реальная сила декларативных языков - и это очень практическая ценность, имеет решающее значение. Декларативные языки предлагают четкий, последовательный, рекурсивный подход к итеративному улучшению. Императивных языков нет.
Брюс

1
+1 за «получить заполнение неокончательных дискуссий о том, что хорошо, декларативный Пролог»… очень верно, что мы склонны не соглашаться!
Даниэль Лайонс

50

Логические языки уже делают это. Вы можете определить сортировку так же, как вы делаете это.

Основная проблема - производительность. Компьютеры могут быть хороши в вычислении многих вещей, но они по своей природе глупы. Каждое «умное» решение, которое может принять компьютер, было запрограммировано в нем программистом. И это решение обычно описывается не тем, как выглядит конечный результат, а тем, как шаг за шагом достичь этого конечного результата.

Представьте себе историю голема . Если вы попытаетесь дать ему абстрактную команду, то в лучшем случае он сделает это неэффективно, а в худшем случае нанесет вред себе, вам или кому-то еще. Но если вы опишите, что вы хотите, в мельчайших деталях, вы гарантированно, что задача будет выполнена эффективно и результативно.

Задача программиста - решить, какой уровень абстракции использовать. Для приложения, которое вы создаете, собираетесь ли вы перейти на более высокий уровень и описать его абстрактно и понизить производительность или понизить ее, потратить на нее в 10 раз больше времени, но получить алгоритм, который в 1000 раз эффективнее?


6
Это может помочь узнать, что слово Голем גולם на самом деле означает «сырье», то есть самое основное состояние, в котором может находиться машина / объект.
dotancohen

2
Декларативные языки по своей природе не являются препятствием для более низких уровней абстракции. И Haskell, и Standard ML по-разному позволяют вам делать простые декларативные заявления о типах / функциях в одном месте, предоставлять диапазон конкретных и конкретных реализаций функций в отдельном месте и способы сопоставления типов с реализациями в другом. Между тем, лучшая практика в языках OO / Imperative в настоящее время заключается в том, чтобы начинать с high / simple и затем добавлять детали реализации. Разница в том, что высокая абстракция легче в FP, а низкий уровень - обязательно.
Брюс

2
Следует сказать, что на любом из упомянутых языков также возможно автоматически разрешить выбор конкретной реализации на основе свойств типа, а не на основе жесткого соответствия конкретных совпадений, что в значительной степени обеспечивает то, что хочет OP. В Хаскеле классы типов были бы ключевым инструментом для этого. В стандартном ML функторы.
Брюс

22
@BAR Голем! = Голум Голем из еврейского фольклора
Эйфорическая

10
Мой ответ на этот вопрос - написать אמת на моем ноутбуке.
Дан Дж

45

В дополнение к замечательному замечанию Euphoric , я хотел бы добавить, что мы уже используем декларативные языки во многих местах, где они работают хорошо, то есть описывают состояние, которое вряд ли изменится, или запрашиваем что-то, для чего компьютер фактически может генерировать эффективный код сам по себе:

  • HTML объявляет содержание веб-страницы.

  • CSS объявляет, как должны выглядеть различные типы элементов на веб-странице.

  • Каждая реляционная база данных имеет язык определения данных, который объявляет структуру базы данных.

  • SQL гораздо ближе к декларативному, чем к императивному, поскольку вы сообщаете ему, что хотите видеть, и планировщик запросов базы данных выясняет, как на самом деле это сделать.

  • Можно утверждать, что большинство файлов конфигурации (.vimrc, .profile, .bashrc, .gitconfig) используют домен-специфический язык, который в значительной степени декларативен.


3
Я упомяну GNU Make, XSLT, Angular.js как широко используемые вещи, которые также являются декларативными (хотя angular, возможно, немного продвигает определение).
Марк К Коуэн

Позвольте мне добавить регулярные выражения в этот список.
Шверн

7
Люди склонны забывать, что декларативные языки распространены . Они просто не изучают полные языки. Добавьте регулярное выражение в этот список.
Slebetman

Немного педантично, но все же: не каждая база данных имеет DDL, просто представьте себе огромное количество бессхемных баз данных NoSQL. Каждая реляционная база данных может иметь, но не каждая база данных.
Восстановите Монику - Диркк

1
@dirkk Не думал об этом. Исправил мой ответ.
Ixrec

17

Абстракции дырявые

Вы можете реализовать декларативную систему, в которой вы объявляете, что хотите, а компилятор или интерпретатор определяет порядок выполнения. Теоретическое преимущество заключается в том, что это освобождает вас от необходимости думать о том, «как», и вам не нужно подробно описывать эту реализацию. Однако на практике для вычислений общего назначения вам все еще нужно подумать о «как» и написать всевозможные трюки, помня, как это будет реализовано, поскольку в противном случае компилятор может (и часто будет) выбирать реализацию, которая будет очень, очень, очень медленно (например, n! операций, где n будет достаточно).

В вашем конкретном примере, вы получите A алгоритма сортировки - это не значит , что вы получите хороший или даже несколько полезных один. Ваше заданное определение, если оно реализовано буквально (вероятно, компилятором), приводит к http://en.wikipedia.org/wiki/Bogosort, который непригоден для больших наборов данных - это технически правильно, но для сортировки тысячи чисел требуется вечность ,

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


Хотя то, что вы говорите, по существу верно, плохая производительность не является признаком утечки абстракции, если интерфейс / контракт не дает вам гарантий, например, о времени выполнения.
Valenterry

3
Петерс не говорит, что плохая производительность является признаком неплотной абстракции, @valenterry. Во всяком случае, он говорит об обратном: чтобы добиться хорошей производительности, детали реализации вынуждены просачиваться.
Брюс

2
Я думаю, что вводить в заблуждение утверждение о том, что абстракции являются утечками, просто потому, что вам нужно понимать реализацию, чтобы понять, как она влияет на производительность. Цель абстракции - не защитить вас от необходимости думать о производительности.
Доваль

1
@jamesqf В декларативном программировании вы бы просто заявили, что что-то отсортировано. Вы можете объявить порядок сортировки привязанным к некоторой переменной / свойству. И тогда это было бы так. Нет необходимости явно вызывать метод сортировки при каждом добавлении новых данных или изменении порядка сортировки.
Гайд

1
@jamesqf Вы не можете увидеть смысл, даже не попробовав себя (я бы порекомендовал QML из Qt для игры с декларативными идеями). Представьте себе кого-то, кто знает только императивное программирование, и кто пытается понять суть ООП или функционального программирования, не испытывая его на самом деле.
Гайд

11

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

Многие проблемы, которые относительно легко излагать, оказались неразрешимыми или имеют сложность, полную NP-решения. Это часто происходит, когда мы принимаем во внимание отрицательные классы и классификацию, счетность и рекурсию.

Я хотел бы привести пример с некоторыми хорошо известными доменами.

Решение о том, какой класс CSS использовать, требует знания и рассмотрения всех правил CSS. Добавление новых правил может сделать недействительными все другие решения. Негативные CSS-классы намеренно не добавляются в язык из-за проблем с NP-полнотой, но отсутствие негативных классов усложняет решения CSS.

В оптимизаторе запросов (SQL) возникает сложнейшая проблема - решить, в каком порядке присоединиться, какие индексы использовать и какую память выделить для каких временных результатов. Это известная NP-полная проблема, которая усложняет разработку базы данных и формулировку запросов. Чтобы сформулировать это по-другому: при проектировании базы данных или запроса разработчику необходимо знать действия и порядок действий, которые, вероятно, предпримет оптимизатор запросов. Опытный инженер нуждается в знании эвристики, используемой основными поставщиками баз данных.

Файлы конфигурации являются декларативными, но некоторые конфигурации сложно объявить. Например, для правильной настройки функций необходимо учитывать управление версиями, развертывание (и историю развертывания), возможные ручные переопределения и возможные конфликты с другими настройками. Для правильной проверки конфигурации может возникнуть NP-полная проблема.

В результате эти сложности застают начинающих врасплох, они нарушают «красоту» декларативного программирования и заставляют некоторых инженеров искать другие решения. Миграция неопытных инженеров с SQL на NoSQL могла быть вызвана сложностями реляционных баз данных.


2
«Отрицательные CSS-классы намеренно не добавляются в язык из-за проблем с NP-полнотой» - можете ли вы уточнить?
Джон Дворжак

Это немного упражнение, но с отрицательными селекторами CSS можно переписать их в проблему 3SAT (с последним предложением, являющимся DOM), которая потребует пробовать все возможные комбинации, чтобы проверить, совпадают ли они.
Диббеке

1
Небольшое дополнение. В CSS 3 и 4 допускаются отрицательные селекторы, но: не псевдоклассы не могут быть вложенными.
Диббеке

2

У нас есть разница в декларативности языков программирования, которая находит хорошее применение при проверке цифровой логики.

Обычно цифровая логика описывается на уровне передачи регистров (RTL), где определяется логический уровень сигналов между регистрами. Чтобы убедиться, что мы все чаще добавляем свойства, определенные более абстрактно и декларативно.

Один из более декларативных языков / подмножеств языка называется PSL для языка спецификации свойств. При тестировании модели множителя RTL, в которой, например, должны быть указаны все логические операции сдвига и сложения в течение нескольких тактов; Вы можете написать свойство в порядке assert that when enable is high, this output will equal the multiplication of these two inputs after no more than 8 clock cycles. Затем описание PSL может быть проверено вместе с RTL в симуляции, или может быть формально доказано, что PSL сохраняется для описания RTL.

Более декларативная модель PSL вынуждает описывать то же поведение, что и описание RTL, но достаточно другим способом, который можно автоматически проверять по RTL, чтобы увидеть, согласны ли они.


1

В основном проблема в том, как вы моделируете данные; и декларативное программирование здесь не помогает. В императивных языках у вас уже есть тонны библиотек, которые многое для вас делают, так что вам нужно только знать, как звонить. В частности, можно рассмотреть это декларативное программирование (вероятно, лучший пример для этого - Stream API в Java 8 ). Имея это, абстракция уже решена, и декларативное программирование не требуется.

Также, как уже было сказано, языки логического программирования уже делают именно то, что вы хотите. Кто-то может сказать, что проблема в производительности, но с сегодняшним оборудованием и исследованиями в этой области, вещи могут быть улучшены, чтобы быть готовыми к производственному использованию; на самом деле Пролог используется для ИИ, но я верю только в академические круги.

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


3
Моделирование данных? Вы выбрали вещь, в которой императивы хуже всего. Декларативные функциональные языки, такие как Haskell и ML, превосходны в моделировании данных. Например, алгебраические типы данных и рекурсивные типы данных обычно могут быть определены всесторонне в одну или две строки. Конечно, у вас все еще есть функции для написания, но их код неотвратимо вытекает из определения типа и ограничен этим. Странное сравнение сделать.
Брюс

1
@itsbruce Дело в том, что большинство реальных данных не так просто сопоставить с ADT; подумайте, как работает большинство баз данных. Как Пролог - Эрланг, вы правы, это разные языки. Я упомянул, что один функционален, а другой логичен, но лучше, если я удалю все сравнение.
m3th0dman

1
@ m3th0dman База данных - это просто тонна кортежей / записей. Хаскель там немного хромает, потому что ему не хватает записей, но у него есть кортежи, а у ML есть и то, и другое. И в случае с Haskell количество стандартного шаблона, необходимого для объявления нового типа псевдозаписи, все еще намного меньше, чем требуется для создания искусственной структуры в среднем статически типизированном языке ООП. Можете ли вы уточнить, как большинство данных не так легко сопоставить с ADT?
Доваль

1
@ m3th0dman Вот почему схемы баз данных определены на императивном языке, который хорошо подходит для этой задачи. О нет, это были бы декларативные DDL. Фактически, общий процесс моделирования данных имеет отношение к приложению, которое будет работать с ним, потокам данных и структурам, а не к языку, реализующему приложение. Иногда они искажаются, чтобы соответствовать функциям OO языка и тому, что поддерживает его ORM, но обычно это плохо, а не функция. Декларативные языки лучше подходят для выражения концептуальной / логической модели данных.
Ибрус Брюс

1
@itsbruce Я не говорил, что процедурная парадигма лучше, чем декларативная, при определении данных; Я говорил, что декларативная парадигма не лучше (и не хуже), чем процедурная (для языков общего назначения). Что касается манипулирования данными, декларативной части SQL недостаточно для реальных приложений; иначе никто бы не придумал и не использовал процедурные расширения. Что касается статьи, я не согласен с ней из абстрактного, где она противоречит Бруксу; он строил свои идеи из реальных проектов, в то время как эти парни не создавали ничего выдающегося, чтобы доказать свою теорию.
m3th0dman

0

Это выглядело бы примерно так .. {(что угодно => прочитать файл и вызвать URL) | вызывать URL и читать файл} Однако это действия, которые нужно выполнить, и в результате состояние системы изменяется, но это не очевидно из источника.

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

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

Я знаю, это звучит немного странно, но в 2008 году я написал программный генератор, который использует этот метод, и сгенерированный C ++ в 2–15 раз больше исходного кода. Теперь у меня более 75 000 строк C ++ из 20 000 строк ввода. С этим связаны две вещи: последовательность и полнота.

Согласованность: никакие два предиката, которые могут быть истинными в одно и то же время, не могут подразумевать противоречивые действия, такие как x = 8 и x = 9, а также различные следующие состояния.

Полнота: указана логика для каждого перехода состояния. Это может быть трудно проверить для систем с N подсостояниями с> 2 ** N состояниями, но есть интересные комбинаторные методы, которые могут проверить все. В 1962 году я написал фазу 1 системной сортировки для машин 7070, используя этот тип генерации условного кода и комбинаторную отладку. Из 8000 строк такого рода количество ошибок со дня первого выпуска навсегда было равно нулю!

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


1
Это действительно не отвечает на оригинальный вопрос. Как согласованность и полнота влияют на тот факт, что большая часть программирования все еще является процедурной, а не декларативной?
Джей Элстон

Ваш вступительный абзац, кажется, является ответом на пункт в ответе arnaud programmers.stackexchange.com/a/275839/67057 , а не на сам вопрос. Там должен быть комментарий (на моем экране ваш ответ уже не ниже его, во-первых). Я думаю, что остальная часть вашего ответа является иллюстрацией того, как небольшое количество декларативного кода может генерировать большое количество эквивалентного императивного кода, но это не ясно. Ваш ответ нуждается в аккуратности, особенно в отношении существенных моментов.
Брюс

-3

Не все может быть представлено декларативным способом.

Часто вы явно хотите контролировать поток выполнения

Например, следующий псевдокод: if whatever read a file call an URL else call an URL write a file Как бы вы представили его декларативно?

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

Все сводится к тому, что «взаимодействие» с вашей средой / системой не декларативно. Все, что связано с вводом / выводом, является процедурным по сути. Вы должны точно сказать, когда и что должно произойти, и в каком порядке.

Декларативный отлично подходит для всего, что связано исключительно с вычислениями. Как гигантская функция, вы вводите X и получаете Y. Это здорово. Примером этого является HTML, ввод - это текст, вывод - это то, что вы видите в своем браузере.


2
Я не покупаю это. Почему ваш пример не декларативный? Это if/ else, в каком случае будет выглядеть декларативная альтернатива? Это, конечно, не части read/ write/ call, так как они являются хорошими декларативными списками значений (если вы подразумеваете, что они заключены в оболочку {...; ...}, почему бы не подразумевать, что они заключены в оболочку [..., ...]?) Конечно, список - это просто свободные моноиды; многие другие тоже сделали бы. Я не понимаю, почему монады здесь актуальны; они просто API. Haskell пошел от потоков -> монад, чтобы помочь ручному составлению, но языки логики могут составлять списки автоматически.
Warbo

2
-1 только для монад. 1. Они не очень экзотичны (списки и наборы - это монады, и все их используют). 2. Они не имеют ничего общего с принуждением к выполнению действий в определенной последовательности ( нотация на Haskell do выглядит обязательной, но не обязательной). Декларативные / функциональные языки определяют отношения и зависимости. Если функция X требует ввода Y, Y будет сгенерирован до X. Получите правильные зависимости, и правильная последовательность событий определит сама себя. Большая часть взаимодействия основана на событиях , а не в одной последовательности. Декларативные языки не усложняют реакцию на события.
Брюс

Лень несколько усложняет это, но лень не является частью определения декларативных языков, большинство из которых не используют его. И в тех, которые делают, способ гарантировать оценку не имеет ничего общего с монадами. В качестве примера того, где декларативный язык используется исключительно для взаимодействия, а не абстрактных вычислений, не определяет порядок, но, черт побери, уверяет, что правильные вещи происходят последовательно, смотрите не дальше, чем в Puppet DSL. Который имеет преимущество в том, что происходят только необходимые вещи, а не что-то обязательное для языков.
Брюс

1
В дополнение к примеру @itsbruce реактивное программирование считается декларативным и касается взаимодействия с окружающей средой.
Мацей Пехотка
Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.