Хабрахабр

[Перевод] Глобальные состояния: зачем и как их избегать

Глобальные состояния. Эта фраза вызывает страх и боль в сердце каждого разработчика, кто имел несчастье столкнуться с этим явлением. Вы уже сталкивались с неожиданным поведением приложений, не понимая его причин, словно несчастный рыцарь, пытающийся убить Гидру со множеством голов? Вы попадали в бесконечный цикл проб и ошибок, 90 % времени гадая, что же происходит?

Конечно, не нравится. Всё это может быть раздражающими последствиями глобалов: скрытых переменных, меняющих своё состояние в неизвестных местах, при причинам, которые вы ещё не выяснили.
Вам нравится блуждать во тьме, пока вы пытаетесь изменить приложение? К счастью, у меня есть для вас свечи:

  1. Во-первых, я опишу то, что мы чаще всего называем глобальными состояниями. Этот термин не всегда применяется точно, поэтому требует пояснения.
  2. Далее мы узнаем, чем глобалы вредны для нашей кодовой базы.
  3. Затем я объясню, как урезать область видимости глобалов, чтобы превратить их в локальные переменные.
  4. И наконец, я расскажу об инкапсуляции и о том, почему крестовый поход против глобальных переменных является лишь частью большой проблемы.

Надеюсь, эта статья объяснит вам всё, что нужно знать о глобальных состояниях. Если вам покажется, что я многое упустил и вы меня за это ненавидите и не хотите меня больше видеть, напишите об этом в комментариях. Это будет приятно мне, моим читателям и всем, кто неожиданно оказался на этой странице.

Вперёд, найдём эти глобалы и заставим их отведать вкус стали наших мечей! Ты готов, дорогой читатель, вскочить на коня и познать своего врага?

Начнём с основ, чтобы вы, разработчики, понимали друг друга.

Состояния встречаются в реальной жизни: Состояние (state) — это определение системы или сущности.

  • Когда компьютер выключен, его состояние — выключен.
  • Когда чашка чая горячая, её состояние — горячая.

В разработке ПО некоторые конструкции (например переменные) могут иметь состояния. Скажем, строка «hello» или число 11 не считаются состояниями, они значения. Они становятся состоянием, когда прикрепляются к переменной и помещаются в память.

<?php echo "hello"; // No state here!
$lala = "hello"; // The variable $lala has the state 'hello'.

Можно выделить два вида состояний:

Изменяемые состояния: после своей инициализации могут меняться в ходе исполнения вашего приложения в любой момент.

<?php $lala = "hello"; // Initialisation of the variable.
$lala = "hallo"; // The state of the variable $lala can be changed at runtime.

Неизменяемые состояния: не могут меняться в ходе исполнения. Вы присваиваете своей переменной первое состояние, и его значение впоследствии уже не меняется. «Константами» в обиходе называют примеры неизменяемых состояний:

<?php define("GREETING", "hello"); // Constant definition.
echo GREETING; GREETING = "hallo"; // This line will produce an error!

Теперь давайте послушаем гипотетическую беседу между Денисом и Василием, вашими коллегами-разработчиками:

Ты везде насоздавал глобальные переменные! — Дэн! Я тебя прибью!
— Нифига, Васёк! Их нельзя поменять без того, чтобы всё не сломалось! Я вложил в них душу, это шедевры! Мои глобальные состояния офигенные! Я обожаю свои глобалы!

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

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

<?php namespace App\Ecommerce; $global = "I'm a mutable global variable!"; // global variable class Shipment

} class Product
{ public function __construct() { global $global; $global = "I change the state now 'cause I can!"; echo "You're creating a product object!"; // no state here }
}

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

Если вы и правда так подумали, очень рекомендую продолжить чтение.

Самая большая диаграмма связей

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

Почему?

Каждый раз, когда вам нужно что-то поменять, вам придётся: Допустим, у вас большое приложение с глобальными переменными.

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

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

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

Если вам нужно поменять состояние глобалов, то вы не будете представлять, на какую область видимости это повлияет. Это ещё не всё. Успехов в поиске. Приведёт ли это к неожиданному поведению другого класса, метода или функции?

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

Когда мы пытаемся так делать, то быстро истощаем запас когнитивных возможностей, нам становится трудно сосредоточиться, и мы начинаем создавать баги и глупости. Люди плохо отслеживают сразу большой объём информации. Вот почему так неприятно действовать в глобальной области видимости вашего приложения.

Коллизии имён глобалов

Есть сложности с использованием сторонних библиотек. Представим, что вы хотите использовать вон ту суперкрутую библиотеку, которая случайным образом раскрашивает каждый символ с эффектом мерцания. Мечта каждого разработчика! Если эта библиотека тоже использует глобалы, у которых те же имена, что и у ваших собственных, то вы насладитесь коллизиями имён. Ваше приложение обрушится и вы будете гадать о причинах, вероятно, долго:

  • Во-первых, вам понадобится выяснить, что ваша библиотека использует глобальные переменные.
  • Во-вторых, вам понадобится вычислить, какая переменная использовалась в ходе исполнения — ваша или библиотеки? Это не так просто, имена-то одинаковые!
  • В-третьих, раз вы не можете самостоятельно изменить библиотеку, придётся переименовать свои глобальные изменяемые переменные. Если они использованы по всему приложению, вы будете рыдать.

На каждом этапе вы будете рвать волосы от ярости и отчаяния. Скоро вам уже не понадобится расчёска. Вряд ли вас соблазняет такой сценарий. Возможно, кто-то вспомнит, что JavaScript-библиотеки Mootools, Underscore и jQuery всегда конфликтовали друг с другом, если их не помещать в более мелкие области видимости. А, и знаменитый глобальный объект $ в jQuery!

Тестирование превратится в кошмар

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

Нет? У вас когда-нибудь было так, что в изоляции тесты работают нормально, а когда запускаешь весь пакет, они сбоят? Каждый раз, когда об этом вспоминаю, я страдаю. А у меня было.

Проблемы с параллелизмом

Изменяемые глобальные состояния могут доставить много проблем, если вам необходим параллелизм (concurrency). Когда вы меняете состояние глобалов в нескольких потоках исполнения, то по уши вляпаетесь в мощное состояние гонки.

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

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

Эти HTTP-параметры, отправленные на сервер, могут быть востребованы на многих уровнях вашего приложения. Возьмём REST API: конечные точки получают какие-то HTTP-запросы с параметрами и отправляют ответы. Добавляем сверху в каждый запрос параллелизм, и рецепт катастрофы готов. Очень соблазнительно сделать эти параметры глобальными при получении HTTP-запроса, модифицировав их перед отправкой ответа.

Например, в PHP есть суперглобалы. Глобальные изменяемые состояния могут также напрямую поддерживаться в реализации языка.

Как рефакторить приложение Дениса, вашего коллеги-разработчика, который создал глобалы везде, где только можно, потому что за последние 20 лет он ничего не читал по разработке? Если у вас откуда-то взялись глобалы, то как с ними быть?

Аргументы функций

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

<?php namespace App; use Router\HttpRequest;
use App\Product\ProductData;
use App\Exceptions; class ProductController
{ public function createAction(HttpRequest $httpReq) { $productData = $httpReq->get("productData"); if (!$this->productModel->validateProduct($productData)) { return ValidationException(sprintf("The product %d is not valid", $productData["id"])); } $product = $this->productModel->createProduct($productData); }
} class Product
{ public function createProduct(array $productData): Product { $productData["name"] = "SuperProduct".$productData["name"]; // This is not what you should do; I talk about it later in the article. try { $product = $this->productDao->find($productData["id"]); return product; } catch (NotFoundException $e) { $product = $this->productDao->save($productData); return $product; } }
} class ProductDao
{ private $db; public function find(int $id): array { return $this->db->find(['product' => $id]); } public function save(array $productData): array { return $this->db->saveProduct($productData); }
}

Как видите, массив $productData из контроллера, через HTTP-запрос, проходит через разные уровни:

  1. Контроллер получил HTTP-запрос.
  2. Параметры переданы в модель.
  3. Параметры переданы в DAO.
  4. Параметры сохранены в базе данных приложения.

Мы могли бы сделать этот массив параметров глобальным, когда извлекли его из HTTP-запроса. Кажется, что так проще: не нужно передавать данные в 4 разные функции. Однако передача параметров в качестве аргументов функций:

  • Очевидно покажет, что эти функции используют массив $productData.
  • Очевидно покажет, что какие функции используют какие параметры. Видно, что для ProductDao::find из массива $productData нужен только $id, а не всё подряд.

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

Если нужно добавить ещё больше, то вырастет сложность функции! Вы уже слышите, как Денис протестует: «А если у функции уже три и более аргументов? Будете передавать их каждой функции в приложении?». И что насчёт переменных, объектов и других конструкций, которые везде нужны?

Как хороший разработчик, вы должны объяснить Денису, использовав свои навыки общения, вот что: Вопросы справедливые, дорогой читатель.

Вероятно, они делают слишком много, отвечают за слишком много вещей. «Денис, если в твоих функциях слишком много аргументов, то проблемой могут быть сами функции. Ты не думал разделить их на более мелкие функции?».

Ощущая себя оратором в афинском Акрополе, вы продолжаете:

Но если они и правда тебе нужны, то что плохого в передаче их через аргументы функций? «Если тебе нужны переменные во многих областях видимости, то это проблема, и скоро мы об этом поговорим. Да, тебе придётся их набрать на клавиатуре, но мы же разработчики, это наша работа — писать код».

Может показаться более сложным, когда у тебя больше аргументов (возможно, это так), но повторюсь, достоинства перевешивают недостатки: лучше, чтобы код был как можно понятнее, а не использовать скрытые глобальные изменяемые состояния.

Контекстные объекты

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

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

Затем контекстный объект будет передан любому методу, которому понадобятся эти данные. Контекстом будет сам запрос: другой запрос — другой контекст — другой набор данных.

Вы скажете: «Это офигенно и всё такое, но что это даёт?»

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

Но у всего в разработке есть цена. Контекстные объекты могут вредить:

  • Глядя на аргументы функции, вы не будете знать, какие данные лежат в контекстном объекте.
  • В контекстный объект можно положить что угодно. Осторожнее, не положите слишком много, например, всю пользовательскую сессию, или даже большую часть данных вашего приложения. А то может получиться такое: $context->getSession()->getUser()->getProfil()->getUsername(). Нарушите закона Деметры, и вашим проклятием станет безумная сложность.
  • Чем больше контекстный объект, тем сложнее узнать, какие данные и в какой области видимости он использует.

В общем, я бы избегал использования контекстных объектов по мере сил. Они могут повлечь немало сомнений. Неизменяемость данных — большой плюс, но нельзя забывать и о недостатках. Если используете контекстный объект, убедитесь, что он достаточно маленький, и передавайте его в маленькую и тщательно определённую область видимости.

Поэтому их некоторые фреймворки их используют, вспомните, к примеру, объект Request в Symfony. Если перед исполнением программы вы понятия не имеете, сколько состояний будет передано вашим функциям (например, параметры из HTTP-запроса), то контекстные объекты могут быть полезны.

Внедрение зависимостей

Другой хорошей альтернативой глобальным изменяемым состояниям будет прямое внедрение нужных вам данных в объект прямо при его создании. Это определение внедрения зависимости: набора методик для внедрения объектов в ваши компоненты (классы).

Почему именно внедрение зависимостей?

Цель — ограничить использование ваших переменных, объектов или иных конструктов, поместить их в ограниченную область видимости. Если у вас есть зависимости, которые внедрены, а следовательно могут действовать только внутри области видимости объекта, то вам будет проще узнать, в каком контексте они используются и почему. Никакой тоски и мучений!

Внедрение зависимостей делит жизненный цикл приложения на две важные фазы:

  1. Создание объектов приложения и внедрение их зависимостей.
  2. Использование объектов для достижения ваших целей.

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

Но вовсе не обязательно всё усложнять. Многие фреймворки используют внедрение зависимостей, иногда в довольно сложных схемах, с конфигурационными файлами и Dependency Injection Container (DIC). Например, в мире Go я не знаю никого, кто использовал бы DIC. Вы можете просто создавать зависимости на одном уровне и внедрять их уровнем ниже. Можно также инстанцировать всё подряд в разные пакеты, чтобы чётко обозначить, что «фаза внедрения зависимостей» должна выполняться только на этом конкретном уровне. Ты просто создаёшь зависимости в основном файле с кодом (main.go), а затем передаёшь их на следующий уровень. В Go области видимости пакетов могут сделать какие-то вещи проще, чем в PHP, в котором DIC’и широко применяются в каждом известном мне фреймворке, в том числе в Symfony и Laravel.

Внедрение через конструктор или сеттеры

Есть два способа внедрить зависимости: через конструктор или сеттеры. Я советую, по возможности, придерживаться первого способа:

  • Если вам нужно знать, какие есть зависимости у класса, вам достаточно найти конструктор. Не нужно искать разрозненные по всему классу методы.
  • Настройка зависимостей при установке даст вам уверенность в безопасности использования объекта.

Немножко поговорим о последнем пункте: это называется «применение инварианта» (enforcing invariant). Создавая экземпляр объекта и внедряя его зависимости, вы знаете: что бы ни понадобилось вашему объекту, он настроен правильно. А если вы используете сеттеры, как вы узнаете, что ваши зависимости уже заданы в момент использования объекта? Можете пойти в стек и попробовать выяснить, вызывались ли сеттеры, но я уверен, что вам не хочется этим заниматься.
В конце концов, единственное различие между локальными и глобальными состояниями — это их области видимости. Они ограничены у локальных состояний, а для глобальных доступно всё приложение. Однако вы можете столкнуться с проблемами, характерными для глобальных состояний, если используете состояния локальные. Почему?

Ты сказал «инкапсуляция»?

Использование глобальных состояний в конце-концов нарушит инкапсуляцию, так же как вы можете нарушить её с локальными состояниями.

Что нам говорит Википедия про определение инкапсуляции? Начнём с начала. Ограничение доступа? Языковой механизм ограничения прямого доступа к каким-то компонентам объекта. Зачем?

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

Растущая область видимости и утечки состояний

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

По сути, Anemic Domain Model делит данные и поведение ваших доменных объектов на две группы: модели (объекты только с данными) и сервисы (объекты только с поведением). Возьмём пример: Anemic Domain Model может увеличивать область видимости ваших изменяемых моделей. Следовательно, есть вероятность, что какой-то модели будет всё время расти область видимости. Чаще всего эти модели будут использоваться во всех сервисах. Вы не будете понимать, какая модель в каком контексте используется, их состояние изменится, и на вас обрушатся все те же проблемы.

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

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

Следуйте бизнес-модели вашей компании. Как определить такие области видимости? Приложение должно быть зеркалом бизнеса, а значит области видимости должны содержать состояния и поведения, отражающие бизнес-модель.

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

Возможности копирования состояний

Во многих случаях хорошим решением будет копирование состояний без их прямого изменения. Вернёмся к нашему примеру с Product, точнее, к этому методу:

class Product
{ public function createProduct(array $productData): Product { $productData["name"] = "SuperProduct".$productData["name"]; // This is not what you should do; I talk about it later in the article. try { $product = $this->productDao->find($productData["id"]); return product; } catch (NotFoundException $e) { $product = $this->productDao->save($productData); return $product; } }
}

Массиву $productData лучше оставаться неизменяемым. Если вы напрямую поменяете его состояние, а затем передадите другим функциям, то вскоре просто не сможете узнать, какое состояние принял этот массив.

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

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

Лучше сделать так:

class Product
{ public function createProduct(array $productData): Product { // Since $productData is passed to other variable, it has to be immutable. $name = "SuperProduct".$productData["name"]; try { $product = $this->productDao->find($productData["id"]); return product; } catch (NotFoundException $e) { $product = $this->productDao->save($name, $productData); return $product; } }
}

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

Вы даже можете изолировать это изменение состояние в отдельном методе, чтобы было ещё очевиднее: «Внимание, я сейчас меняю это состояние».

Например, такое состояние у констант. В любых приложениях можно встретить глобальные неизменяемые состояния.

Безопасно ли их использовать?

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

Но всё же глобальные неизменяемые состояния нельзя считать совсем безобидными. Вдумчиво решайте, какая часть приложения какие состояния использует.

А если Денис, ваш коллега-разработчик, начнёт использовать ShipmentDelay для другой задержки, не относящейся к отгрузкам, то ваш глобал будет использоваться там, где он не имеет смысла. К примеру, константа ShipmentDelay будет использована, надеюсь, только там, где реализована логика отгрузки товара. Я видел много разработчиков, которые делают подобные странные вещи во имя священного принципа DRY. Глупо?

Решить эту проблему можно с помощью рефакторинга: соберите все части в один класс, в набор классов, в пакет или другую удобную для вас конструкцию. Если глобальные неизменяемые состояния у вас осмысленно используются по всему приложению, это может говорить о разделённости поведений, которые должны быть собраны вместе. Если вы можете откуда угодно обращаться к глобальным неизменяемым конструкциям, это ещё не значит, что вы должны обращаться к ним откуда угодно. Таким образом, используя константы или иные глобальные неизменяемые состояния, будьте внимательны и следите, чтобы они не расползались по приложению.

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

Поэтому помните: Однако приложениям свойственно увеличиваться.

  • Нужно по мере возможности избегать глобальных изменяемых состояний.
  • Уменьшить область видимости любого глобального изменяемого состояния можно с помощью аргументов функций, внедрения зависимостей и контекстных объектов.
  • Глобальные переменные — лишь верхушка айсберга: на самом деле нам важно соблюдать общий принцип инкапсуляции, который нарушается разными путями, лишь одним из которых являются глобальные изменяемые состояния.
  • Глобальные неизменяемые состояния менее вредны, но их тоже лучше не применять где попало.

В разработке ПО не бывает однозначно хороших и однозначно плохих решений. Всё бывает полезным в зависимости от ситуации, и одна из самых трудных задач — принять верное решение в ваших условиях. Поэтому экспериментируйте, создавайте прототипы и думайте о последствиях.

Однако я считаю, что чаще всего глобальные изменяемые состояния приносят больше вреда, чем пользы.

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

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»