Вебинар: C# разработка и статический анализ: в чем практическая польза? - 18.11
Эпизод 276 CppCast, беседа с Робертом Лихи, 2 декабря 2020 года.
Мы решили познакомить вас, наших читателей, с зарубежными подкастами, где затрагиваются самые интересные темы про программирование и IT. Поэтому наша команда представляет новый формат статей – текстовые расшифровки подкастов. Мы знаем, что кто-то лучше воспринимает информацию в текстовом варианте, чем на слух, а учитывая тот факт, что материал на английском, появляются дополнительные сложности для понимания сути происходящего в подкасте. Мы надеемся, что формат приживется и будет полезен, как опытным программистам — открыть для себя что-то новое, так и новичкам, которые только начинают свою профессиональную деятельность в сфере IT.
Сегодняшний подкаст посвящен С++. Поговорим о возможностях использования нового кроссплатформенного фреймворка C++ Plywood для создания игр на C++, шокируем читателей "страшной" блок-схемой инициализации C++20, обсудим точность использования исключений на современных 64-битных ПК-архитектурах, а также внедрение экзекьюторов в стандартизацию Networking TS.
Ссылка на оригинал подкаста будет в конце статьи.
Примечание. Текст подкаста был отредактирован с целью скорректировать неточности в речи. Он скорее отражает смысл высказываний, а не дословные утверждения спикеров.
Итак, начнем.
Гость данного эпизода – Роберт Лихи, выпускник Университета Виктории, где он изучал компьютерную графику, теорию игр и цифровую обработку изображений. Четыре с половиной года проработав фулл-стек веб-разработчиком, в начале 2017 года он сменил сферу деятельности, приступив к изучению финансовых технологий. Являясь членом комитета ISO C++, он выступает за высококачественное и процессно-ориентированное программное обеспечение, соответствующее строгим стандартам эффективности, которыми так славится финансовая сфера.
Примечание. Ранее Джефф Прешинг, автор статьи "Игра с открытым свободным кодом Flappy Hero" выложил пост "Как написать кастомный игровой движок на C++", где описывает процесс создания собственной игры на C++.
В статье "Новый кроссплатформенный фреймворк C++ Plywood с открытым исходным кодом" автор отмечает, что фреймворк Plywood – не игровой движок, а фреймворк для создания любого программного обеспечения на C++. Документация Plywood создается с помощью синтаксического анализатора C++, форматируется с помощью анализатора Markdown и запускается на настраиваемом веб-сервере, написанном с использованием Plywood. Интеграция сторонних библиотек в C++ может стать проблемой, но Plywood стремится упростить данный процесс.
Роб Ирвинг: Итак, начнем с простой игры с открытым исходным кодом на C++ на preshing.com. Очень похоже на рекламу Plywood, фреймворка C++, на котором автор написал игру. Демо-игра Flappy Hero является клоном известной игры Flappy Bird, которая произвела фурор в начале 2010-х годов.
Джейсон Тернер: Парень, который создал Flappy Bird, так расстроился из-за всей этой известности, что залег на дно и удалил игру из App Store.
Роб Ирвинг: Он утверждает, что удалил ее только потому, что люди стали одержимы этой игрой. Он хотел, чтобы эта игра просто помогала отвлечься на несколько минут, но люди стали зависимы от нее.
Роберт Лихи: Эта игра стала таким же феноменом в мобильной индустрии, как в свое время стала Sega Genesis. Сейчас в метро каждый второй играет во что-то на телефоне, а до появления Flappy Bird и всего этого, люди читали книги или просто сидели, слушая музыку или что-то в этом роде.
Роб Ирвинг: Простая игра, от которой невозможно оторваться. Играя в нее, можно запросто потеряться.
Роберт Лихи: Я ознакомился с фреймворком Plywood. Интересно, что, похоже, он позволяет объявлять и создавать модули в самом C++. Пишешь небольшую функцию, которая принимает параметр, а затем создаешь свой модуль. Я даже не знал, что так можно на C++.
Роб Ирвинг: Эти модули чем-то похожи на модули C++20?
Роберт Лихи: Согласно информации на сайте, модули Plywood не следует путать с модулями C++20.
Перейдем к следующей теме.
Роб Ирвинг: Теперь обсудим пост из C++-сабреддита, а конкретно блок-схему инициализации C++20. Огромную блок-схему, на которую просто страшно смотреть и осознавать, что инициализация все еще так сложна.
Джейсон Тернер: Все еще? Она даже сложнее последнего C++.
Роб Ирвинг: И становится еще сложнее. Это правда.
Роберт Лихи: Сейчас в комитете стандартизации обсуждают следующее. Если кто-то опубликует предложение в стандарт, которое каким-либо образом упростит язык, все удивятся, как до такого вообще можно было додуматься. Так вот, с инициализацией точно так же. После каждого релиза или во время совещания кто-нибудь обязательно начнет обсуждать отдельные особые случаи: "Вот сейчас оно не работает. Давайте добавим еще пару ветвлений на эту блок-схему, чтобы все заработало. Как думаете, стоит?". PNG-картинка уже настолько большая, что всякий раз, когда я переключаюсь обратно на вкладку, уходит несколько секунд, чтобы она прогрузилась.
Джейсон Тернер: Чувствую я, что некоторые из этих граничных случаев, попавших в блок-схему, являются "мифическими существами", обнаруженными при написании компиляторов во время поиска новых краевых случаев.
Роберт Лихи: Любопытно, как это вообще происходит в программной инженерии. Вроде, все учел и продумал, а приступив к реализации, понял, что ничего не работает. Поэтому я не могу понять, радует или обескураживает то, что такое происходит в комитете. Даже если собрать лучшие умы C++ в одной комнате, они все равно не смогут заменить одного парня, пытающегося провести реализацию компилятора.
Джейсон Тернер: Забавно, хотя автор утверждает, что потратил на это гораздо меньше времени, чем ожидал. Прочитав это перед тем, как кликнуть, я ожидал увидеть относительно небольшую блок-схему. А потом я понял, что придется приближать изображение и крутить вниз, чтобы просмотреть. Если только у вас нет 48-дюймового монитора или чего-то похожего.
Роберт Лихи: У меня есть 49-дюймовый монитор, который все равно не подходит. Точнее подходит, но прочитать ничего не получится. Все равно придется увеличить масштаб.
Джейсон Тернер: Нужно использовать 8K 49-дюймовый монитор, очень близко придвинуться к монитору и только тогда получится это прочитать.
Роб Ирвинг: Следующая новость – это пост в блоге lordsoftech.com, под названием: "Коды ошибок гораздо медленнее, чем исключения". Речь идет о том, что с современными 64-битными архитектурами проблемы ухудшения производительности не должны стать причиной избегания исключений. Тестирования производили с помощью средств синтаксического анализа XML, первый раз реализовали с ошибками в коде, второй – с исключениями. В итоге обнаружили, что код с ошибкой, использующий имплементацию, замедляет работу программы примерно на 6%.
Примечание. Полный исходный код можете найти здесь.
Джейсон Тернер: Соглашусь, но во вложенных кейсах, именно эти коды ошибок будут медленнее, чем исключения, при условии, что исключения действительно исключительны. Я провел собственное исследование по этому вопросу и не совсем согласен с приведенными здесь примерами, потому что автор переключился с API, возвращающего значение, на API, принимающий значение по ссылке. А если бы он вместо этого использовал expected со значением плюс код ошибки в качестве возвращаемого значения, то я думаю, получились бы немного другие значения.
Роб Ирвинг: Можно было провести третью пробную реализацию. Сначала реализовать коды ошибок, затем исключения, а потом с expected.
Роберт Лихи: Думаю, что предложение expected сейчас находится как бы в чистилище комитета по стандартам. Но, благодаря Найлу Дугласу, можно скачать и использовать Boost.Outcome. Правда я почти уверен, что, проходя экспертную оценку в Boost, ему пришлось удалить монадические интерфейсы. Ближе к концу статьи приведен пример упрощения кода с использованием исключений, а в качестве запасного варианта используется макрос PROPAGATE_ERROR.
Я как-то работал с версией expected с монадическим интерфейсом. Отличный способ работы с кодами ошибок. Boilerplate не было, по-моему, тогда это называлось присваиванием значения. На выходе получаем итоговое значение или найденную ошибку. Однако в данной реализации, как и в случае с синтаксическим анализатором XML, генерирующим исключения для плохо сформированного XML, возникают вопросы, не раз обсуждавшиеся в исследовательской группе Networking. Что подразумевается под ошибкой? Чья это ошибка? Редко в файл попадает ошибка? Этот XML исключительный? Читаем случайные файлы и пытаемся выяснить, есть ли в них XML, или получаем входные данные, которые принимаем за XML? Какая ситуация исключительная?
Поскольку ответить на эти вопросы непросто, нужно установить таксономию ошибок, которая сама по себе усложняется. Напоминает ошибку bad_alloc. Правда некоторые искренне верят, что bad_alloc не может произойти. А если и происходит, при работе в Linux, она настигнет тебя, а если нет, не получится ничего восстановить, придется преждевременно завершить работу.
Роберт Лихи: Интересно то, что, даже зная в какой области находишься, невозможно понять, что действительно исключительно, потому что кто-то может попытаться использовать вашу XML-библиотеку, чтобы выяснить, является ли эта строка XML случайной. Здесь дело не в том, что XML неисключительный, а в том, что это только часть возможного развития событий.
Джейсон Тернер: Интересная мысль. Всякий раз, когда студенты спрашивают меня о сравнении кодов ошибок с исключениями или о чем-то еще, я обязательно отмечаю, что главное – быть последовательными в своей кодовой базе. Ненавижу, когда я перехожу к базе кода и не знаю, чего ожидать. Неважно, сигнализируешь ли об ошибке, возвращая "true", возвращая "false" или вообще используя исключения. Главное, чтобы используемые методы согласовывались друг с другом во всем коде.
Роберт Лихи: Насколько я помню, в POSIX при успешном завершении программы возвращают "false"-значения (нулевые значения), а в Windows все наоборот. В итоге, во время программирования на кроссплатформенной операционной системе, однажды точно ошибешься при проверке своей базы кода.
Примечание. Биман Доус был одним из самых влиятельных людей в истории C++. Комитет и сообщества программистов C++ во многом обязаны Биману. Он входил в состав комитета по стандартам C++ (WG21) с 1992 года и возглавлял рабочую группу библиотеки в течение пяти лет в ходе утверждения первого стандарта C++, C++ 98. Биман Доус был разработчиком <filesystem>, входящую в стандарт.
Роб Ирвинг: Недавно до нас дошло печальное известие, ушел из жизни Биман Доус, автор Boost and File System, член комитета ISO. Он привнес много нового в язык C++. Выражаем соболезнования его семье. Мы пытались связаться с ним и пригласить его на шоу около двух лет назад, но встреча не состоялась.
Роберт Лихи: Мне сообщили об этом за 30 минут до шоу, всегда неприятно получать такие новости.
Роб Ирвинг: Роберт, расскажи нам немного о Networking TS. Как это будет выглядеть в C++23?
Роберт Лихи: В комитете хотят по-прежнему использовать экзекьюторы и Networking в C++23. Бесспорно, вся эта история с COVID внесла коррективы в наши планы. Сейчас у 4-й Исследовательской группы много долгосрочных задач и исследований. Чтобы сократить время интеграции с экзекьюторами, некоторые используют Asio, своего рода эталонную реализацию.
Мы надеемся, что экзекьюторы в будущем будут похожи на Asio, но на данный момент TS отстает от них. Чтобы объединить их, потребуется провести много исследований. На каждых переговорах 4-ой Исследовательской группы речь шла об этом. Пока в Networking не завершится работа с экзекьюторами, все переговоры носят предварительный характер.
Мы, вроде, и уверены, как это будет выглядеть в C++23, но вполне возможно, что в последнюю минуту, все неожиданно изменится, и все усилия окажутся напрасны. Это уже не раз обсуждалось в Networking TS.
Роб Ирвинг: Как именно связаны экзекьюторы и предложение Networking?
Роберт Лихи: Думаю, это зависит от уровня, на котором пишется сетевой код. Если код запускается методом бутстрэппинга, экзекьютор, по сути, является способом получить этот фоновый контекст для выполнения работы. Одна из основных проблем асинхронного программирования заключается в том, что при написании синхронного кода, есть контекст выполнения. Начинаешь с main или с некого потока. То есть, есть ЦП, команда выполняется, можно войти в функцию и остаться там, а затем вернуться, и еще остается место для выполнения кода.
При выполнении асинхронного кода все это происходит в фоновом режиме и возникает вопрос: откуда берется контекст для запуска кода? Связь между экзекьютором и аллокатором как бы прерывается.
Когда что-то выполняется асинхронно, экзекьютор определяет, как, где и когда эта работа будет выполняться в фоновом режиме. А если запустить асинхронный сокет, работа завершится. А в следующем фрагменте запускается обработчик завершения, определенный экзекьютором.
Так что, создавая с нуля, нужно быть осторожным. Иначе говоря, приступая к следующим уровням написания асинхронного кода, экзекьютор должен быть выбран в начале.
Когда я пишу код, обычно происходит следующее: я наследую экзекьютор, который использует какой-то сокет или то, что дано. Будучи уверенным, что используешь его экзекьютор даже не задумываешься об этом. А затем, в main, понимаешь, что нужны четыре разных потока, то есть четыре разных контекста, каждый со своим экзекьютором. Раздаешь их, определяется место выполнения работы, поток выполнения и синхронизация, которые хочешь предоставить. Поэтому экзекьютор – это не просто новая фича, а целая концепция.
Джейсон Тернер: Экзекьюторы чем-то похожи на параллельные алгоритмы?
Роберт Лихи: Не уверен, но думаю, что-то общее у них есть. Экзекьюторы более гибкие, их можно написать с нуля.
Джейсон Тернер: Как тогда выглядит интерфейс экзекьютора, написанного с нуля?
Роберт Лихи: Сейчас интерфейс экзекьютора очень прост. Под простым интерфейсом экзекьютора буквально подразумевается один точечный объект настройки, который вызывается экзекьютором, передаешь его экзекьютору и тому, что может быть вызвано без аргументов. Он вызывает объект без аргументов внутри контекста выполнения, который также обрабатывается экзекьюторами.
Роб Ирвинг: Теперь обсудим Boost.Asio. Расскажешь нам немного о том, как это будет выглядеть после выхода Networking TS?
Роберт Лихи: Дело в том, что Networking и Asio сейчас недостаточно функциональны. Получаешь сокет TCP, сокет UDP или даже сырой сокет. Мы же хотим предоставить возможность создавать все, что захотите в C++. Многие хотят иметь доступ к TLS по умолчанию, что действительно имеет смысл. Например, если писать приложение для телефона, логично сделать так, чтобы создать незащищенное соединение было невозможно. Потому что, если разрешить людям делать это, большинство так и поступит, будет использовать соединение без защиты. Это все усложнит, что будет небезопасно по умолчанию, мы этого не хотим. Но в то же время TS и Asio не стремятся работать с низкоуровневым компоновочным блоком.
Джейсон Тернер: Итак, к вопросу о получении сокета TLS по умолчанию. Поддерживает ли Networking TS шифрование?
Роберт Лихи: В самом предложении Networking нет возможностей для шифрования, но они есть в Asio. В комитете также обсуждалось, какое шифрование принимается за стандарт, проведена ли реализация, а если проведена, то, в чем ее преимущество. Лично я думаю, что стандартизация какой-либо формы шифрования пошла бы на пользу, но мы хотим видеть Networking TS в 23. Мы можем добавить что-то, чтобы сделать шифрование.
Например, в Asio поддержка TLS – это всего лишь пара классов. Не нужно вносить какие-то принципиальные изменения, но не все операционные системы поддерживают это. Полагаю, что некоторые мобильные операционные системы не могут создавать сырые TCP-соединения без определенного разрешения, которого нет у большинства приложений. Я думаю, что в iOS такое есть, хотя я могу ошибаться насчет этого.
Джейсон Тернер: То есть, по сути, ты получаешь TCP, UDP, IPV для IPv6?
Роберт Лихи: Да, но не уверен, есть ли сырые сокеты в TS. В Asio они есть, но они скорее похожи на асинхронный аналог сокетов Беркли. Вообще многое в TS похоже на POSIX. Не знаю, есть ли такое в TS, но уверен, что в Asio есть ICMP.
Самое важное, что мы получим от TS в сочетании с экзекьюторами, это модель того, как бы выглядел и функционировал асинхронный ввод-вывод, оставляя возможность компиляции библиотек, которые просто принимают параметр шаблона, являющегося асинхронным потоком или что-то в этом роде. Здесь все взаимосвязано, не важно, используются ли IOUring, порты завершения (IOCP), файл или сокет.
Мы как бы имеем заготовки для выполнения самых простых операций с сокетами. Не знаю, в каком объеме, но IP/TCP, UDP, а также V4 и V6 определенно получим.
Джейсон Тернер: Итак, сейчас нам остается ждать завершения работы с экзекьюторами, а по большей части предложение Networking почти готово к реализации.
Роберт Лихи: Все стабильно, документы на рассмотрении. В последний раз нам удалось договориться об обновлении некоторых старых идиом, чтобы привести TS в соответствие с экзекьюторами TS. Использовались старые паттерны из C++11.
Джейсон Тернер: Я думал, что корутины, экзекьюторы и предложение Networking как-то связаны. А придется ли вносить изменения предложение Networking, чтобы воспользоваться преимуществами сопрограмм, или они уже были внесены?
Роберт Лихи: Насколько я помню, больше года назад в Белфасте мы одобрили этот документ. Нам очень понравилась эта идея, потому что структура TS включает в себя механизм, называемый completion tokens. То есть, когда передаешь последний аргумент, когда начинается операция, последний аргумент не является обработчиком завершения. Необязательно вызывается функция. Токен дает нам возможность узнать, какую функцию следует использовать для обозначения завершения. Разница в том, что можно полноценно настроить механизм отчетов о завершении. Итак, волшебным образом можно выполнить любую операцию в Networking TS или написанную в стиле Networking TS, и можно передать ей токен под названием Use Future. И внезапно, вместо вызова функции, операция возвращает future и внутри использует promise, совершенно незаметно для пользователя.
Что касается вашего вопроса, хочется ответить "да", однако есть большое "но". Проблема в том, что как только вызывается так называемая функция инициирования, то есть функция, которая запускает процесс, операция уже началась, она как бы вернулась к тебе. И часто где-то в фоновом режиме уже запущена операция. Пытаясь преобразовать свою инициирующую функцию во что-то, что использует сопрограммы, тот факт, что она была отключена и запущена в фоновом режиме и могла завершиться, означал, что между первой приостановкой сопрограммы и возможным возобновлением произошло состояние гонки. В итоге, пытаясь превратить любую из этих операций во что-то, что использует сопрограммы, нужно использовать мьютекс, что, по сути, противоречит C++ и абстракциям с нулевой стоимостью.
Что касается механизма работы completion tokens, при настройке, функция запуска может работать как функция, которая инкапсулирует то, что операция будет выполнять, чтобы запуститься и запустить целую кучу аргументов. Можно немного отложить это и начать операцию снова позже. Таким образом, можно незаметно преобразовать один из них во что-то, что использует сопрограммы. Он просто начнет так называемое инициирование, чтобы начать выполнение операции. Он фиксирует все аргументы, помещает их куда-то, а затем ожидает первой приостановки работы сопрограммы и начинает операцию.
Крис, автор документа N3747, хотел создать что-то вроде поддержки сопрограмм, а для этого ему нужно было везде добавить мьютекс (mutex), что не улучшает производительность. Множество механизмов, которые он внедрил значительно упрощают реализацию самих операций. Вызываешь одну вспомогательную функцию, передаешь ее лямбде, и она делает все за вас. Так мы получаем возможность написать операцию, которая поддерживает сопрограммы, promises, futures и вообще все, о чем только мечтаешь. Крис называет это универсальной моделью для асинхронных операций. Если загуглить универсальную модель асинхронных операций, то можно найти документ Криса о ранней версии completion tokens, но принципы все те же.
Джейсон Тернер: Используются ли специальные стратегии выделения памяти или полиморфные аллокаторы в стандарте C++?
Роберт Лихи: Мы их не используем, скорее, из-за финансового вопроса. Я вообще стараюсь не использовать статическое распределение памяти. А стратегия двойной буферизации как раз помогает в этом. Если волнует только пропускная способность, то тот факт, что часть данных стала доступной на микросекунды раньше, не имеет значения.
Главное — это то, что в какой-то момент при работе с очень большим соединением с высокой пропускной способностью, я останавливаю распределение. То есть, мы пытаемся создать буфер для отправки по TCP. Когда размер этого буфера превысит 10 килобайт, необходимо отправить его, поменять местами буферы и заполнить следующий. Допустим, мы используем буфер памяти из библиотеки format. В итоге он заполняется, потому что он работает в JSON. Они как бы векторные. Их размер уменьшается. А затем, когда помещаешь в них JSON, они начинают распределяться и изменяться в размере. В какой-то момент вам понадобиться выполнить самое большое распределение. И после этого они больше не будут заполняться, а соединение может выдать огромное количество данных. Буквально десятки или сотни гигабайт данных. И он просто больше не распределяет, потому что он помещает JSON непосредственно в этот буфер с помощью библиотеки форматов, буфер заполняется полностью. Так, что больше не нужно все это распределять. А затем он просто заполняет этот буфер. Итак, пока он ожидает команды от операционной системы, он все еще генерирует данные из внутренней базы данных, продолжая менять их местами.
Наша компания обрабатывает данные рынка в реальном времени. Мы применяем всевозможные стратегии распределения. Потому что здесь имеет значение каждая микросекунда инкрементной задержки.
Джейсон Тернер: У меня есть еще один вопрос. Я заметил, что некоторые из самых крупных стандартных библиотек, Boost.Regex, Filesystem, Ranges, Parallel Algorithms отстают в разработке фактической реализации внутри стандартных библиотек, таких как LIB C++ или Clang, которая пока не имеет параллельных алгоритмов. Как думаешь, после утверждения Networking и внедрения экзекьюторов, не столкнемся ли мы с такой же проблемой при реализации нашей стандартной библиотеки?
Роберт Лихи: Хороший вопрос. Я не думаю, что будет много проблем. Возможно, Asio будет использоваться в качестве эталонной реализации. Крис много делает для того, чтобы обеспечить совместимость работы Networking с Asio на данном этапе. Так что, думаю, нам повезет. Реализация зависит от вендоров. Если они захотят создать свою библиотеку с нуля, на это уйдет немало времени. А библиотека TS уже довольно большая, и много чего предстоит реализовать, как и с экзекуторами, поэтому затрудняюсь ответить.
Я знаю, что управление пакетами в C++ выглядит весьма странно. Если хочется использовать Networking TS прямо сейчас, привыкай использовать Boost.Asio и Asio и получишь даже больше, чем предлагает TS. Будет возможность использовать синхронную обработку уникальных сигналов, что действительно полезно. В TS такая возможность даже не рассматривается. В таком случае не нужно будет загружать Boost. Многие недолюбливают Boost, поэтому Крис предоставляет автономный доступ к Asio. Ты просто можешь использовать Asio и все. Мы часто используем Boost, но не в этом проекте. Здесь мы используем автономный Asio, который отлично работает.
Я бы посоветовал тем, кто не боится управлять пакетами и зависимостями между ними, использовать Asio, пока TS не достигнет достаточного уровня. Это прекрасная возможность не зависеть от некоторых языковых особенностей.
На этом текстовая трансляция подкаста заканчивается. Спасибо, что уделили время, надеемся, вы получили много новой информации, которая пригодится в будущем.
В разделе "Ресурсы" можете найти все необходимые ссылки на информацию из приведенного текста, а также ссылки на ведущих данного эпизода. Надеемся, такой формат будет интересен вам в дальнейшем.
Спасибо за внимание, до новых встреч! :)
Подкаст
Новости
Ссылки
Ведущие
Спонсоры
Спонсор этого эпизода CppCast – PVS-Studio. Команда продвигает регулярное использование статического анализа кода и инструмент статического анализа PVS-Studio, предназначенный для обнаружения ошибок в коде программ на языках C, C++, C# и Java. Компания предоставляет платный инструмент, а также различные бесплатные варианты лицензий для разработчиков открытых проектов, экспертов Microsoft MVP, студентов и других. Анализатор активно развивается, проводит регулярные тесты на поиск новых ошибок, расширяет возможности интеграции. Например, недавно PVS-Studio разместила на своем сайте статью, посвященную анализу pull request-ов в Azure DevOps при помощи self-hosted агентов. Используйте промокод #cppcast на странице загрузки для получения лицензии на месяц.
А также JetBrains, генератор умных идей и создатель инструментов IntelliJ, pyCharm, ReSharper. Чтобы помочь вам стать гуру C++, они предлагают CLion, IntelliJ IDEA и ReSharper C++ – многофункциональное расширение для Visual Studio. Исключительно для CppCast, JetBrains предлагает скидку 25% на ежегодные индивидуальные лицензии для обоих инструментов C++, которая распространяется на новые покупки и продления. Используйте код JetBrainsForCppCast во время оформления заказа по адресу JetBrains.com для получения скидки, не упустите такую возможность!
0