Pull to refresh

А ваш язык программирования необоснованный? (или почему предсказуемость важна)

Reading time 15 min
Views 35K
Original author: Scott Wlaschin
Как должно быть очевидно, одна из целей этого сайта — убедить принимать F# всерьёз в роли универсального языка разработки.

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

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

Не сомневаюсь, что теперь, когда и в Java тоже добавлены лямбды, подобные комментарии зазвучали в экосистеме JVM при обсуждении «Scala и Closure против Java».

Так что в этой статье я собираюсь отойти от F# и сосредоточиться на C# (а на его примере и на других популярных языках), чтобы показать, что даже с реализацией всех мыслимых средств функциональных языков программирование на C# никогда не будет таким же, как на F#.

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

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

(Теперь я надеваю свой огнеупорный костюм. Думаю, он мне понадобится!)

ДОПОЛНЕНИЕ: похоже, что многие читатели совсем не поняли эту статью. Позвольте я проясню:

  • я не говорю, что языки со статической типизацией «лучше», чем с динамической.
  • я не говорю, что функциональные языки «лучше», чем объектно-ориентированные.
  • я не говорю, что возможность продумывать код является наиболее важным аспектом языка.

Я говорю, что:

— невозможность продумывать код приносит издержки, в которых многие разработчики не отдавали себе отчёта.
— более того, возможность «продумывания» должна быть только одним (из многих) фактором оценки, когда выбирается язык программирования, просто о нём не стоит забывать из-за недостатка предостережений.
— ЕСЛИ вы хотите иметь возможность продумывать свой код, ТО наличие в вашем языке свойств, о которых я расскажу, сделает это намного проще.
— фундаментальные концепции объектно-ориентированного программирования (уникальность объектов, ориентированность на поведение) не совместимы с «продумыванием», и поэтому будет сложно доработать существующие ОО языки, чтобы добавить эту возможность.

Больше ничего. Спасибо!


Всё-таки, что же такое «обоснованный» язык программирования?


Если вы знакомы с функциональными программистами, то часто слышите словосочетание «продумывать что-то», например «мы хотим продумать наши программы».

Что это значит? Зачем использовать слово «продумывать» вместо обычного «понять»? Использование «продумывания» берёт начало в математике и логике, но я собираюсь использовать простое и практичное определение:

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

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

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

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

Пример 1


Давайте приступим к рассмотрению следующего кода.

  • Мы начинаем с переменной x, которой присваивается целочисленное значение 2.
  • После этого вызывается DoSomething, которой x передаётся как параметр.
  • И после этого переменной y присваивается результат выражения x — 1.

Вопрос, который я задам, не сложен: каково значение y?

var x = 2;
DoSomething(x);

// Каково значение y? 
var y = x - 1;

Ответ
Ответом будет -1. Вы получили такой результат? Нет? Если вы не можете понять это, смотрите далее.

Расшифровка
Шуточный вопрос! Этот код написан на JavaScript! Вот как он выглядит полностью:

function DoSomething (foo) { x = false}

var x = 2;
DoSomething(x);
var y = x - 1;

Да, это ужасно! DoSomething получает доступ к x напрямую, а не через параметр, и (вот тебе раз!) превращает его в переменную с булевым типом. После этого вычитание единицы превращает x из false в 0 (с преобразованием типа), и y в результате получается -1.
Вы полностью взбешены? Простите, что сбил с толку вас подменой языка, но я хотел просто продемонстрировать как это бесит, когда язык ведёт себя непредсказуемым образом.

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

Благодаря статической типизации и разумным правилам определения области видимости ошибки такого рода не могут возникать в коде на C# (только, конечно, если не очень постараетесь). В C# если правильно не подобрать типы, возникает ошибка времени компиляции, а не времени выполнения.

Другими словами, C# намного более предсказуем, чем JavaScript. Первый балл за статическую типизацию! Итак мы получили первую рекомендацию для создания предсказуемого языка.

Как сделать язык предсказуемым:

1. Переменным не разрешается менять тип.

C# выглядит неплохо по сравнению с JavaScript. Но мы ещё не закончили…

ДОПОЛНЕНИЕ: Это был заведомо глупый пример. Если бы мог вернуться в прошлое, я бы выбрал пример получше. Да, я знаю, что ни один разумный человек не будет делать так. Но суть дела не меняется: JavaScript не запрещает вам делать глупости с неявным приведением типов.


Пример 2


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

 // создаём двух заказчиков
var cust1 = new Customer(99, "J Smith");
var cust2 = new Customer(99, "J Smith");

// истина или ложь?
cust1.Equals(cust2);


Ответ
Кто знает. Это зависит от того, как Customer реализован. Этот код не предсказуем.
Вы должны, по крайней мере, узнать реализован ли в классе IEquatable, и вероятно вы будете должны посмотреть код реализации класса, чтобы понять что конкретно там происходит.

Но почему этот вопрос вообще возникает? Позвольте мне у вас спросить:

— Как часто вы НЕ хотите, чтобы экземпляры были приравнены?
— Как часто вам приходилось переписывать метод Equals?
— Как часто вы сталкивались с ошибкой из-за того, что забыли переопределить метод Equals?
— Как часто вы сталкивались с ошибкой, вызванной неправильной реализацией метода GetHashCode (например, забыли изменить его результат при изменении полей, по которым проводится сравнение)?

Почему не определить по умолчанию сравнение объектов (экземпляров классов) по равенству их атрибутов, а сравнение по равенству их ссылок сделать особым случаем? Так что давайте добавим ещё один пункт в наш список.

Как сделать язык предсказуемым:

1. Переменным не разрешается менять тип.

2. Объекты с идентичным содержанием должны быть равными по умолчанию.

Пример 3


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

// создаём заказчика и счёт
var cust = new Customer(99, "J Smith");
var order = new Order(99, "J Smith");

// истина или ложь?
cust.Equals(order);

Ответ
Ну кого это может волновать! Это определённо ошибка! Во-первых, зачем вообще сравнивать объекты двух различных классов? Сравнивайте их имена или идентификаторы, только не сами объекты. Здесь должна возникать ошибка при компиляции. Но если это не так, то почему бы и нет? Вероятно вы только что ошибочно использовали имя не той переменной, и теперь получили в своём коде ошибку, которую нелегко отловить. Почему ваш язык позволил вам это?

Добавим ещё один пункт в наш лист.

Как сделать язык предсказуемым:

1. Переменным не разрешается менять тип.
2. Объекты с идентичным содержанием должны быть равными по умолчанию.
3. Сравнение объектов с разными типами должно вызывать ошибку времени компиляции.

ДОПОЛНЕНИЕ: многие утверждают, что это необходимо для сравнения классов, находящихся в отношении наследования. Конечно, это верно. Но какова цена этой возможности? Вы получаете возможность сравнивать дочерние классы, но теряете способность обнаруживать случайные ошибки. Что в реальной работе более важно? Это решать вам, я только хотел явно показать что у принятой практики есть и недостатки, а не только преимущества.

Пример 4


В этом фрагменте кода мы просто создадим экземпляр класса Customer. И всё. Не могу придумать что-то более простое.

// создаём заказчика
var cust = new Customer();

// что ожидается на выходе?
Console.WriteLine(cust.Address.Country);

Теперь вопрос в следующем: какой вывод мы ожидаем от WriteLine?

Ответ
Да кто ж его знает. Это зависит будет ли свойство Address равно null или нет. И это что-то, что опять невозможно определить без того, чтобы заглянуть во внутренности класса Customer.

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

Ещё один пункт в наш список.

Как сделать язык предсказуемым:

1. Переменным не разрешается менять тип.
2. Объекты с идентичным содержанием должны быть равными по умолчанию.
3. Сравнение объектов с разными типами должно вызывать ошибку времени компиляции.
4. Объекты всегда должны быть инициализированными до корректного состояния. Невыполнение этого требования должно приводить к ошибке времени компиляции.

Пример 5


В следующем примере мы проделаем следующее:
— создадим заказчика
— добавим его в хеш-множество
— сделаем что-нибудь с объектом заказчика
— попробуем найти заказчика в множестве

Что может пойти не так?

// создаём заказчика
var cust = new Customer(99, "J Smith");

// добавляем его в множество
var processedCustomers = new HashSet<Customer>();
processedCustomers.Add(cust);

// обрабатываем его
ProcessCustomer(cust);

// Он всё ещё в множестве? истина или ложь?
processedCustomers.Contains(cust);

Итак, содержит ли множество объект заказчика после выполнения этого кода?

Ответ
Может быть да. А может и нет.
Это зависит от двух моментов:
— во-первых, зависит ли хеш-код от модифицируемого поля, например, такого как идентификатор?
— во-вторых, меняет ли ProcessCustomer это поле?

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

Один способ — сделать немодифицируемыми поля, используемые в GetHashCode, и оставить модифицируемыми все остальные. Но это очень неудобно.

Вместо этого лучше сделать немодифицируемым весь класс Customer! Теперь, если объект Customer неизменяемый, и ProcessCustomer захочет сделать изменения, он будет обязан вернуть новую версию объекта заказчика, а код будет выглядеть например так:

// создаём заказчика
var cust = new Customer(99, "J Smith");

// добавляем его в множество
var processedCustomers = new HashSet<Customer>();
processedCustomers.Add(cust);

// обрабатываем его и возвращаем изменения
var changedCustomer = ProcessCustomer(cust);

// истина или ложь?
processedCustomers.Contains(cust);

Заметьте, что вызов ProcessCustomer изменён на

var changedCustomer = ProcessCustomer(cust);

Теперь ясно, что ProcessCustomer что-то изменяет, если просто посмотреть на эту строку. Если ProcessCustomer ничего не изменяет, то объект и вообще незачем возвращать.

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

Разумеется, это не даёт ответа на вопрос должен ли в множестве присутствовать новый объект, или старый объект (или оба вместе). Но в сравнении с версией с модифицируемым объектом заказчика, описанная проблема теперь упирается вам прямо в лоб и не может быть случайно упущена. Так что немодифицируемость на коне!

Это ещё один пункт в нашем списке.

Как сделать язык предсказуемым:

1. Переменным не разрешается менять тип.
2. Объекты с идентичным содержанием должны быть равными по умолчанию.
3. Сравнение объектов с разными типами должно вызывать ошибку времени компиляции.
4. Объекты должны всегда быть инициализированными в корректное состояние. Невыполнение этого требования должно приводить к ошибке времени компиляции.
5. После создания объекты и коллекции должны оставаться неизменными.

Момент как раз для шутки о немодифицируемости:

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

Почти закончили — остался один!

Пример 6


В этом заключительном примере мы попробуем получить заказчика из CustomerRepository.

// создаём репозиторий
var repo = new CustomerRepository();

// ищем заказчика по идентификатору
var customer = repo.GetById(42);

// что мы ожидаем на выходе?
Console.WriteLine(customer.Id);

Вопрос: после того как мы выполнили:

var customer = repo.GetById(42)

, каково значение customer.Id?

Ответ
Конечно, это всё неопределённо.

Если я посмотрю на сигнатуру метода GetById, она скажет мне, что он всегда возвращает объект Customer. Но так ли это?

Что происходит, когда заказчик не найден? repo.GetById вернёт null? Или выбросит исключение? Вы не сможете это определить, просто глядя на этот фрагмент.

Что касается null, это ужасный вариант для возвращаемого значения. Это оборотень, который прикидывается заказчиком и может быть присвоен переменным типа Customer совершенно без звука со стороны компилятора, но когда вы попросите его что-нибудь сделать, он взорвётся вам в лицо злобным хохотом. Как ни печально, но я не могу определить вернётся или не вернётся из этого метода null.
Исключения только немногим лучше, потому что, по крайней мере, они типизированы и содержат информацию о контексте. Но по сигнатуре метода совершенно невозможно понять какие исключения могут быть выброшены. Единственный способ удостовериться — заглянуть внутрь исходного кода метода (или в документацию, если вам повезло, и она обновляется).

А теперь представьте, что ваш язык не разрешает использовать null и не позволяет выбрасывать исключения. Что бы вы тогда могли сделать?
Чтобы ответить на это, вам пришлось бы возвращать специальный класс, который способен содержать в себе или объект заказчика, или ошибку, например, так:

// создаём репозиторий
var repo = new CustomerRepository();

// ищем заказчика по идентификатору
// и возвращаем результатом объект типа CustomerOrError
var customerOrError = repo.GetById(42);

Код, который будет обрабатывать результат customerOrError, должен проверить какого вида ответ и обработать по отдельности каждый вариант:

// обработать оба случая
if (customerOrError.IsCustomer)
    Console.WriteLine(customerOrError.Customer.Id);

if (customerOrError.IsError)
    Console.WriteLine(customerOrError.ErrorMessage);

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

И теперь, наконец, два последних пункта в списке.

Как сделать язык предсказуемым:

1. Переменным не разрешается менять тип.
2. Объекты с идентичным содержанием должны быть равными по умолчанию.
3. Сравнение объектов с разными типами должно вызывать ошибку времени компиляции.
4. Объекты должны всегда быть инициализированными в корректное состояние. Невыполнение этого требования должно приводить к ошибке времени компиляции.
5. После создания объекты и коллекции должны оставаться неизменными.
6. Запретить использование null.
7. Отсутствие данных или возможность ошибки должны быть представлены явно в сигнатуре метода.


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

Может ваш язык программирования делать это?


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

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

Кроме того, с точки зрения ОО, способ, которым должны сравниваться два объекта, зависит полностью от их реализации — ОО полностью посвящено полиморфизму поведения, так что компилятор здесь не должен лезть не в своё дело! Это же касается и порядка создания, и порядка инициализации объектов, которые полностью определяются в них самих. Здесь не существует законов, которые говорят разрешать это или не разрешать.

В конечном итоге, очень сложно добавить ссылочные типы с запретом нулевых ссылок в статически типизируемый ОО язык без реализации ограничений инициализации по четвёртому пункту. Как сказал Эрик Липперт "Запрет null-ссылок — это штука, которую легко предусмотреть в системе типов в день первый, но не то, что вы захотите доработать спустя двенадцать лет."

Напротив, в большинстве функциональных языков эти «предсказательные» инструменты реализованы в ядре языка.

Например, в F# все пункты списка, кроме одного, встроены в язык:
1. Значения не могут менять свой тип. (И это включает даже невозможность неявного приведения целого числа к плавающему).
2. Переменные типа запись с идентичным внутренним наполнением РАВНЫ по умолчанию.
3. Сравнение значений с разными типами ВЫЗЫВАЕТ ошибку времени компиляции.
4. Значения ДОЛЖНЫ быть инициализированы до корректного состояния. Невыполнение этого требования приводит к ошибке времени компиляции.
5. После создания объекты НЕ модифицируемы по умолчанию.
6. Значения null НЕ разрешены, в большинстве случаев.

Пункт 7 не проверяется компилятором, но для возврата ошибок, как правило, используются не исключения, а типы дизъюнктивного объединения (сум-типы), так что сигнатура функции чётко показывает какие возможные ошибки она может возвращать.

Конечно, при работе с F# у вас всё-таки остаётся множество проблемных моментов. Вы можете получить модифицируемые значения, вы можете создавать и бросать исключения, и вы можете на самом деле столкнуться с null, которые передаются из не-F# кода. Но эти проблемы будут возникать в коде как дурно пахнущие кучки, потому что не являются стандартными и не используются в обычной практике.

Другие языки, например, Хаскелл, являются даже более пуританскими, чем F#, а значит даже лучше поддерживают продумывание, но даже на Хаскелле программы не могут быть идеальными. В самом деле, ни один язык не может быть идеально предсказуемым и, в то же время, оставаться практичным. И всё-таки некоторые языки более предсказуемы, чем другие.

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

Лямбды — это не выход


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

То есть, если я должен выбирать между языком А, который не разрешает nullы, и языком B, который поддерживает параметрический полиморфизм высшего порядка, но разрешает объектам быть nullами, я без колебаний выбираю А.

Комментарии


Дайте-ка я попробую предугадать несколько комментариев…

Комментарий: Эти примеры высосаны из пальца! Если писать аккуратно и следовать правильным методикам, можно писать безопасный код без этих фенечек!

Да, можно. Я не утверждаю, что вы не можете. Но эта статья не о написании безопасного кода, она об обдумывании кода. В этом есть разница.
И она не о том, что вы сможете сделать, если будете аккуратны. Она о том, что может случиться, если вы не будете аккуратны! То есть, помогает ли ваш язык программирования (а не стандарты кодирования, не тесты, не IDE, не методики разработки) размышлять над вашим кодом?

Комментарий: Вы говорите, что язык иметь эти способности обязан. Не очень ли бесцеремонно это с вашей стороны?

Пожалуйста, читайте внимательно. Я вообще не говорю этого. Вот о чём я говорю: ЕСЛИ вы хотите иметь возможность размышлять над кодом, ТОГДА будет значительно легче это делать с языком, который содержит возможности, о которых я упоминал. Если размышление над кодом не имеет значения для вас, пожалуйста, со спокойной душой проигнорируйте всё, что я сказал!

Комментарий: концентрация только на одном аспекте языка программирования слишком ограничивает. Разве другие качества не являются столь же важными?

Да, конечно, являются. Я не деспот в этом вопросе. Я считаю, что такие свойства, как комплексные исчерпывающие библиотеки, хорошие инструменты разработки, дружественное сообщество и жизнеспособность экосистемы также очень важны. Но целью этой статьи было ответить на конкретные вопросы, которые я упоминал в начале, например, «В C# уже присутствует большинство средств F#, зачем беспокоиться и переходить?»

Комментарий: Почему вы так быстро списали динамические языки?
Во-первых, приношу свои извинения разработчикам на JavaScript за шпильку в их адрес! Мне очень нравятся динамические языки, а один из моих любимых языков, Smalltalk, совершенно не обеспечивает размышление над кодом в соответствии с принципами, о которых я говорил. К счастью, эта статья не попытка уговорить вас какие языки считать вообще «лучшими», а всего лишь обсуждение одного из аспектов выбора языка.

Комментарий: немодифицируемые структуры данных медленны, и с ними выполняется громадное количество дополнительных распределений памяти. Не пострадает ли производительность?

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



Выводы


Я только что сказал, что эта статья не является попыткой склонить вас к выбору языка с поддержкой «продумывания кода». Но это не совсем правда.
Если вы уже выбрали статически типизированный, высокоуровневый язык (как C# или Java), становится ясно, что «продумывание кода» или что-то наподобие этого было важным критерием при принятии решения.

В таком случае я надеюсь, что примеры в этой статье могут заставить вас подумать об использовании более «рассуждаемого» языка на платформе, которую вы выбрали (.NET или JVM).

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

И вот что замечательно в F# или Scala/Clojure — эти альтернативные функциональные языки не заставят вас переходить в другую экосистему, но в то же время мгновенно улучшат качество вашего кода.

Мне кажется, что это сравнительно небольшой риск по отношению к затратам.
Tags:
Hubs:
+25
Comments 195
Comments Comments 195

Articles