Versions Compared

Key

  • This line was added.
  • This line was removed.
  • Formatting was changed.

...

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

...

Как хорошо известно, существует три основных парадигмы программирования (примечание от формалистаПеданта: есть и другие парадигмы):

...

Все эти подходы так или иначе работают с функциями. Давайте посмотрим на это с точки зрения разрешения функций, или диспетчеризации их вызовов (имеется в виду выбор функции, которая должна быть использована в данном месте). Для процедурного программирования характерно использование глобальных функций и их статическое разрешение, основанное на имени функции и типах аргументов. Конечно, типы могут быть использованы только в случае статически типизированных языков. Например, в Python функции вызываются по имени, и если аргументы неправильные, в конце концов возбуждается исключение в рантайме (во время выполнения программы). Разрешение функций в языках с процедурным подходом основано только на имени процедуры/функции и ее параметрах, и в большинстве случаев делается статически.

Объектно-ориентированный стиль программирования стремится к ограничению области видимости функций. Функции не глобальны, вместо этого они являются частью классов, и могут быть вызваны только для экземпляра соответствующего класса (Примечание от формалистапримечание Педанта:  некоторые классические процедурные языки имеют модульную систему и, значит, области видимости; процедурный язык != С). Конечно, мы всегда можем заменить функцию-члена класса глобальной функцией с дополнительным аргументом, имеющим тип вызываемого объекта, но с синтаксической точки зрения разница довольно значительна. Например, в этом случае методы сгруппированы в классе, к которому они обращаются, и поэтому более ясно видно какое поведение обеспечивают объекты данного типа. Конечно, наиболее важны здесь инкапсуляция, благодаря которой некоторые поля класса или его поведение могут быть приватными и доступными только членам этого класса (вы не можете обеспечить этого в чисто процедурном подходе), и полиморфизм, благодаря которому фактически используемый метод определяется не только на основе имени метода, но и на основе типа объекта из которого он вызывается.  Диспетчеризация вызова метода в объектно-ориентированном подходе зависит от типа объекта, определяемого в рантайме, имени метода, и типа аргументов на этапе компиляции.

Функциональный подход не приносит чего-то принципиально нового в плане разрешения функций. Обычно функционально-ориентированные языки имеют лучшие правила для разграничения областей видимости (Примечание от формалистапримечание Педанта: еще раз, C - это еще не все процедурные языки, есть такие, в которых области видимости хорошо разграничены), которые позволяют проводить более скрупулезный контроль видимости функций на основе системы модулей, но кроме этого, разрешение проводится во время компиляции основываясь на типе аргументов.

...

Kotlin дает нам совершенно новую "игрушку" - функции-расширения. (Примечание от формалистаПеданта:  на самом деле не такие уж новые, в C# они тоже есть). Вы можете определить функцию вроде A.doASomething() где угодно в программе, не только внутри A. Внутри этой функции у нас есть неявный this-параметр, называемый получателем (receiver), указывающий на экземпляр A на котором метод вызывается:

...

В начале статьи мы обсудили разные подходы к диспетчеризации вызовов функций, и это было сделано не просто так. Дело в том, что функции-расширения в Kotlin позволяют работать с диспетчеризацией по-новому. Теперь решение о том, какая конкретно функция должна быть использована, основано не только на типе ее параметров, но и на лексическом контексте ее вызова. То есть то же самое выражение в разных контекстах может иметь разное значение. Конечно, с  точки зрения реализации ничего не меняется, и у нас по-прежнему есть явный объект-получатель, который определяет диспетчеризацию для себя своих методов и других объектов, использованных (question) в его расширениях-членах класса расширений, описанных в теле самого класса (member extensions) - но с точки зрения синтаксиса, это другой подход.

...

Причина здесь в том, что невозможно согласованно определить арифметические операции для всех числовых типов. К примеру, деление целых чисел отличается от деления чисел с плавающей точкой. В некоторых особых случаях пользователь знает, какой тип операций нужен, но обычно нет смысла определять такие вещи глобально. Объектно-ориентированным (и, на самом деле, функциональным) решением было бы определить новый тип-наследник класса Number, нужные операции в нем, и использовать его где необходимо (в Kotlin 1.3 можно использовать встраиваемые (inline) классы). Вместо этого, давайте определим контекст с этими операциями и применим его локально:

...

Code Block
fun NumberOperations.calculate(n1: Number, n2: Number) = (n1 + n2)/2

val res = DoubleOperations.calculate(n1, n2)

Это означает, что логика (question) внутри логика операций внутри контекста полностью отделена от реализации этого контекста, и может быть написана в другой части программы или даже в другом модуле. В этом простом примере контекст - это синглтон без состояния, но можно использовать и контексты с состоянием.

...

Это дает эффект комбинирования поведений обоих классов, однако данную фичу на сегодняшний день трудно контролировать из-за отсутствия расширений с множественными получателями (KT-10468).

Мощь явных

...

корутин (coroutines)

Один из лучших примеров контекстно-ориентированного подхода использован в библиотеке KotlinKotlinx-сопрограммcoroutines. Объяснение идеи можно найти в статье Романа Елизарова. Здесь я только хочу подчеркнуть, что CoroutineScope - это случай контекстно-ориентированного дизайна с контекстом, имеющим состояние. CoroutineScope играет две роли:

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

Также, структурированная конкурентность (question) дает (structured concurrency) предоставляет отличный пример контекстно-ориентированной архитектуры:

...

Существует широкий класс задач для Kotlin, на которые обычно ссылаются как на задачи построения DSL (Domain Specific Language). Под DSL при этом понимается некоторый код, обеспечивающий дружественный пользователю построитель (builder) какой-то сложной внутри структуры. На самом деле использование термина DSL здесь не совсем корректно, т.к. в таких случаях просто используется базовый синтаксис Kotlin без каких-либо специальных ухищрений - но давайте все-таки использовать этот распространенный термин.

...

Другой пример - построитель GUI TornadoFX. Весь построитель графа сцены (question) устроен сцены устроен как последовательность вложенных контекст-построителей, где внутренние блоки отвечают за построение детей для внешних блоков или подстройку параметров родителей. Вот пример из официальной документации:

...

На данный момент разработка в контекстном подходе ограничена тем фактом, что нужно определять расширения, чтобы получить какое-то ограниченное контекстом поведение класса. Это нормально, когда речь идет о пользовательском классе, но что если мы хотим то же самое для класса из библиотеки? Или если мы хотим создать расширение для уже ограниченного в области поведения (например, добавить какое-то расширение внутрь CoroutineScope)? На данный момент Kotlin не позволяет функциям-расширениям иметь более одного получателя. Но множественные получатели можно было бы добавить в язык, не нарушая обратной совместимости. Возможность использования множественных получателей сейчас обсуждается (KT-10468) и будет оформлена в виде KEEP-запроса (UPD: уже оформлена). Проблема (или, может быть, фишка) вложенных контекстов - в том, что они позволяют покрыть большинство, если не все, варианты использования типовых классов типов (type-classes), другой очень желанной из возможный фич. Довольно маловероятно, что обе эти фичи будут реализованы в языке одновременно.

...

Я хочу поблагодарить нашего дружественного формалиста штатного Педанта и любителя Haskell Алексея Худякова за его замечания по тексту статьи и поправки по моему достаточно вольному использованию терминов. Также благодарю Илью Рыженкова за ценные замечания и вычитку английской версии статьи.Поскольку Энди Дайер (нужен линк (question) ) попросил меня добавить эту статью в ProAndroidDev, несколько слов про Android. Я разработал несколько приложений для Android лет пять назад, и понимаю что экосистема с тех пор существенно изменилась. Тут я могу быть не в курсе многих событий. Тем не менее, хороший код сегодня - это, как правило, декларативный код, и это особенно верно для GUI. По-моему, любой декларативный код выиграет от использования контекстно-ориентированного стиля.