Комментарии 36
Спасибо за статью! Насколько я понимаю, у Вашего термина "континуация" есть устоявшийся аналог - "продолжение".
Вы могли бы уточнить по коду. .run(globalThreadPool()) -- подразумевается что таким образом указывается, где должны выполняться продолжения (зарегистрированные раннее в then)?
В run
передается контекст выполнения, в котором будет выполняться Task
целиком. Идея здесь в том, что пока run
не вызван, ничего не происходит — запрос по сети не отправляется и т.п. Это означает, что в пользовательском коде вы сначала можете спокойно выстроить цепочку вычислений через then
, и уже после этого отправить ее на выполнение — например, в глобальном пуле потоков.
Благодарю за пояснение! Действительно, напоминает модель stdexec/executors.
А что делать в с вашим АПИ если лэйзи инициализация по какой то причине не вариант? Ну или более конкретно нужно поднасыпать зенов когда лягушку уже варят или уже сварили? А ещё точнее ваш АПИ нарушает ваше 1 правило, таск ран я могу вызвать несколько раз, могу добавить зенов после ран. По вашей лэйзи логике ран должен быть у пула и консумить таск и возвращать какой то токен на котором можно только ждать или спросить о готовности.
По сути run
возвращает как раз такой токен, и консьюмит Task
.
На тему добавления then
к этому токену — это хороший вопрос, но надо смотреть на конкретный юзкейс. Мне в проектах на Qt обычно хватало примерно такого API:
request(url)
.then(&parse)
.notify(object, &Object::method)
.run(globalThreadPool());
Где notify()
работает так же, как QObject::connect
.
Т.е. повторный ран вернёт, токен который что? Комплетед как при кенселе? Или повторный ран кинет исключение? Это конечно вкусовщина, но все же явный мув для таска как то более идиоматичен. И я так подумал, и вижу что ваш токен без зена, по сути ни чем от стд футуры не отличается, я бы даже ее и возвращал, хотя возможно у вас там есть кэнсел и метод узнать про это. Вот вы писали статью про АПИ, а в этом примере ничего толком не показали т.к все декларации нужно самому додумывать, так не годится конечно.
А с зеном на вашей футуре будут точно такие же проблемы за которые вы ругаете кутешные футуры. Вам по сути нужно превратить вашу футуру назад в таск, и тогда уже можно через имеющийся АПИ навесить новых зенов, только из вашего АПИ непонятно где же именно будут работать новые зены, там есть 2 варианта если не успели до окончания первой футуры то тут все ясно на том экзекуторе который был при 2 ране, а если успели то не ясно, на первом экзекуторе или на 2.
Вообщем, опять же вкусовщина, но я вижу ровно 0 принципиальных отличий вашего дизайна от пары стд асинк/футура (ну разве что у вас ещё кэнсел завезли). Другими словами если я могу лэйзи собрать таск, то что мне мешает также лэйзи собрать все лямбды в одну а потом ее родную и отправить в асинк получить футуру и также ждать на ней? Даже больше, согласно вашим же рекомендациям именно так и нужно сделать, т.к. это добавляет ещё больше ортогональности, комбинировать лямбды может понадобиться и в отдельности от асинхронщины. А кэнселейшен токен (если нужен) можно через параметр лямбды передавать и потом через тупл вместе с рещультатам всем последующим лямбдам в цепочке.
Короче я про то что связывать чейнинг лямбд с асинхронщиной да ещё и мешать туда культю в одном флаконе как то противоречит вашим же заветам по АПИ. Но хочу выразить благодарность за примеры, и ссылку на ниблера, у меня просто эта боль есть и мне "скоро" это все рефакторить, теперь я вижу как это красиво сделать, и я тоже на культе сижу это 3 ортогональное измерение для АПИ, которое не нужно подмешивать в таски т.к. у нас не везде разрешено в АПИ ссылаться на кутэ, а в некоторых либах даже и использовать ее низя.
Спасибо за комментарии!
Про then
для future-like объекта, возвращаемого из run
— а там нет then
, в этом-то и смысл. При большом желании можно туда приделать notify
, который по завершению будет посылать Qt'шный сигнал с Qt::QueuedConnection
, и проблемы как с QFuture
там не будет.
Про лямбды — на лямбды в этом примере Task
не заменить потому что Network::request
должен нетривиально взаимодействовать с event loop'ом. В общем, как я написал в статье — коротких ответов нет, если в этот пример глубоко погружаться и честно и качественно дизайнить, то это отдельная большая тема.
Этот пример был про "давайте подумаем, как мог бы выглядеть клиентский код, чтобы было удобно," на идеальность предложенного API я конечно не претендую, да я и сам API не показал по сути X). За проработанном решением этой конкретной проблемы надо конечно идти к Eric Niebler — он над этим думает последние лет 5.
Первая статья, прочитанная мной на Хабре, мало что понял, но было интересно, особенно читать о том о чем знаю немного
Классная статья!
Мой урок из написания API (и пользования чужими) — если вы делаете какое-то сложное поведение, то желательно предусмотреть несколько уровней использования.
- Самый нижний — hacker mode: доступ к элементарным функциям которые пользователь может скомпоновать так, как считает нужным (при условии что имеет достаточный опыт и понимание).
- Средний — reasonable defaults. Почти готовое к употреблению — пользователь создает контекст/меняет поведение только основных параметров. Для основной массы людей.
- Верхний — safe playground. Максимально простое использование даже в ущерб памяти/эффективности. Для знакомства с функционалом и быстрого прототипирования.
Самое страшное — это когда авторы в целом хорошей библиотеки считают что все будут ее использовать ровно в той ситуации как они, и дают только третий или второй-третий уровни. А потом ты оказываешься в ситуации когда надо сделать какой-то нестандарт, и вроде вот она хорошая библиотека, а не лезет! И начинаются извращения с запихиванием круглого кода в квадратное отверстие — а то и пиление новой библиотки с нуля...
Бездумное увлечение абстракциями тоже не есть хорошо. Смотришь порой чужой код и хочется повеситься. Или застрелиться. Или все сразу.
Внешне все красиво. Коротенькие функции, абстракции и все вот это вот. Но когда встает задача досконально разобраться не что это функция делает в принципе, а как именно она это делает в деталях, сталкиваешься тем, что вместо того, чтобы просто пролистать 2-3 экрана линейного кода, тебе приходится в открыть 5-10 окошек из разных модулей и очень внимательно смотреть, постоянно "перепрыгивая" из одного окна в другое и обратно, кто кого откуда вызывает, кто что кому возвращает и т.п. И порой проще забить на все это и тупо простепать под отладчиком. Ей богу, благое желание "улучшить читаемость кода" в итоге тут работает ровно наоборот.
Не говоря уже о том, что некоторое количество времени и ресурсов процессора уходит на многочисленные создания/сворачивание уровней стека, динамическое выделение/освобождение памяти, а в особо тяжелых случаях еще и создание разного рода "безопасных копий" объектов. Но это уже профдеформация - сколько себя помню, всегда приходится заниматься разработкой чего-то такого, что требует крайне высокой эффективности
Лирическое отступление
если кто помнит "Корпорация Бессмертие" Шекли - там главный герой, чтобы с ним ни случалось, в конечном итоге оказывался младшим конструктором яхт
и вот эти лишние выкрутасы со стеком и динамической памятью тут ну совсем ни к чему.
Так что вроде бы концептуально все верно и красиво, но на практике нужна какая-то "чуйка" дабы не удариться в "оверконцептуальность" и сохранить баланс достаточной для легкого вникания в код постороннего человека.
В целом же - согласе практически со всеми Вашими тезисами относительно правил построения API.
Бить на классы можно до посинения, важно тоже знать где остановиться. Попробуйте возвести правило "один класс отвечает за одну вещь" в абсолют. Тогда чуть ли не каждая строчка превращается в отдельный класс. И опять же все это тяжело держать в голове.
STL в качестве примера хорошего API? Честно говоря по-моему это худших пример API, который можно найти. Отдельный std::find_if конечно удобен его разработчику, но не удобен пользователю. Если сравнить STL API с любым другим языком, например C#, то становится просто больно
Отдельный std::find_if конечно удобен его разработчику, но не удобен пользователю.
Смотря что за "пользователь". Если пользователь часто пишет обобщенный код, то подход "работает со всем, по чему можно итерироваться" может оказаться лучше, чем подход "работает со всем, что реализует интерфейс X", потому что требования к тому, с чем этот обобщенный код сможет работать, снижаются. Об этом собственно и написано в статье.
Не знаю как в С#, но многие языки возвращают на find либо элемент, либо bool, что провоцирует плохой код
А stl:
провоцирует написание эффективного кода(например неэффективных операций просто нет в интерфейсе, это не java где на forward list будет operator[]),
унифицирует интерфейс всех контейнеров/view, в том числе пользовательских. Не нужно знать как работать с конкретной коллекцией, у вас есть абстракция итератора и алгоритмы, которые с ними работают, всё просто, если вы посмотрите на другие языки, то окажется, что под каждую "коллекцию" как это там называется часто все методы продублированы и нужно отдельно знать как взаимодействовать с каждой из них. То есть O(N) в голове держать вместо O(1)
Бить на классы можно до посинения, важно тоже знать где остановиться. Попробуйте возвести правило "один класс отвечает за одну вещь" в абсолют. Тогда чуть ли не каждая строчка превращается в отдельный класс. И опять же все это тяжело держать в голове.
Примерно то же самое хотел сказать.
Тут можно себя ограничивать правилом наподобие "не более 2-3 уровней стека на вызов верхнего уровня", памятуя, что объект класса - это как минимум конструктор и уровень стека.
И относится это не только к ООП, но и к процедурному программированию в равной степени.
очень жаль, что вы не сможете сделать vec[10] с такой схемой
Вопрос в том - а нужно ли его делать? А нельзя ли то же самое сделать проще и эффективнее?
Мы очень часто доверяемся тому, что часто не знаем или не понимаем как устроено внутри. А потом жалуемся на то, что новая версия продукта прибавила функциональности на копейку, а ресурсов требует на рубль больше.
А все просто потому, что кто-то "провел рефакторинг кода", концептуально добавив туда мешок абстракций.
Это, кстати, какой-то очень распространенный мем среди тех, кто настрадался с кодом, в котором кто-то наархитектурил. На практике я не видел ни одного примера, когда адекватный вдумчивый рефакторинг делал хуже конечным пользователям.
То есть — да, можно плохо код писать, а можно плохо рефакторить. И то, и другое — плохо.
На деле в кодовой базе на на несколько сотен тысяч строк кода без нормальных абстракций вы будете все время офигевать. Не говоря уже о кодовой базе на 1M+ LOC и больше.
Адекватный - да. Но далеко не весь рефакторинг бывает адекватным, увы.
И офигевать можно и от того, что рефакторинг излишне переобогащен абстракциями. Особенно на большой кодовой базе.
Не говоря уже о том, что после такого рефакторинга нагрузочное регресс-тестировангие может показать отрицательные результаты (работать стало медленнее и тратит больше ресурсов)
Я полностью согласен с тем, что после C# возвращаться к плюсовому STL — это боль. std::ranges
из C++20 и C++23 делают жизнь лучше, превращая алгоритмы в компонуемые абстракции — если еще не попробовали, то рекомендую.
Но до удобства LINQ плюсам конечно очень далеко. Попробуйте набагать в коде с ranges, компилятор выплюнет ошибок на мегабайт. В общем, плюсам есть над чем работать.
Это все не отменяет того, что STL попилен на абстракции очень правильно, а для своего времени вообще был гениальной библиотекой. В очень многих своих проектах я применял схожий с STL подход — данные отдельно, логика отдельно — и это помогало. И этот подход даже не про C++, случайный пример из головы — Redux из JS.
Как же не хватает подобных статей. Нейминг и хотя бы немного продуманная архитектура, хорошие "кубики" из которых собирается программа, продуманные инварианты и банальная логичность сущностей ускоряет разработку, внедрение новых сотрудников, повышает удовлетворённость работой в конце концов.
Но кажется люди этого никогда не поймут и так и будут городить MVP "решение" которое потом будут костылями забивать до отказа, набирая в штат всё новых сотрудников, тратя время на бесконечные баги нетестируемого дерьмокода, тратя на каждую фичу громадное количество времени. потому что непонятно в какой точке вообще эту лапшу менять и тд
Насчёт полиморфизма:
Последнее комбо — это неинтрузивный динамический полиморфизм
В С++ это чаще называется стиранием типов и действительно очень полезно, более того, зачастую это понятнее/легче/производительнее/расширяемее чем принятый в С++ способ на виртуальных функциях.
Остаётся только ждать когда люди это осознают и начнут использовать соответствующие инструменты
может лучше использовать dynamic_cast как готовое решение, вместо сишного подхода в виде enum для типа и даункастинга?
а еще std::variant есть...
В данном конкретном примере даункаст по enum
'у эффективнее, но можно и dynamic_cast
использовать, концептуально это ничего не изменит.
std::variant
здесь скорее всего не подойдет, потому что в реальности у вас будет несколько десятков типов событий, может быть даже сотня, засовывать это все в один std::variant
— оверкилл.
Итого. Авторы QtFuture спроектировали херовый апи. Авторы strcpy спроектировали херовый апи. И поэтому вы несёте свет в массы и учите читателя. Но читатель-то тут причём? Это не мы задизайнили QFuture и strcpy таким уродским образом. Может быть, надо было им статью отправить?
Ну то есть наверняка часть из моих апи могла бы быть лучше. Но их плохость вот нинасколько рядом не стояла с количеством деструктивных последствий от strcpy.
Плюсик статье, впрочем, поставил.
Отличная статья, читается на одном дыхании, спасибо
Метод
then
принимающийQObject *
в качестве контекста работает не так, как опытный пользователь Qt может ожидать — в отличие отQObject::connect
,QFuture::then
не следит за временем жизни переданного объекта, и у вас все упадет
если переданный объект будет уничтожен до вызова континуации.
Это не совсем правда. Несмотря на то, что фактически такая проблема была, она была расценена как баг и исправлена.
О, спасибо за ссылку! Ждем Qt 6.6!
Вот это кстати меня тоже всегда морозило в QFuture
:
When the context object is destroyed, cancellation happens immediately. Previous futures in the chain are not cancelled and keep running until they are finished.
Это прямое следствие того, что QFuture
пытается быть всем сразу.
Надо заметить, что вкус к хорошим (минимальным и ортогональным) абстракциям вообще хорошо прививает книга Банды Четырёх. Она может быть старой и неполной, или неактуальной, потому что там не модный нынче type erasure. Но паттерны подобраны класно. Лаконично и просто, развивают хороший вкус. Что может быть проще и математичнее, чем цепочка ответственности, декораторы и композиты! А паттен "мост" - это сама квинтэссенция ортогональности. Если эту книгу усвоить хорошо, то всякие фреймворки-подходы для GUI или сущностей будешь и понимать и придумывать на ходу.
А ещё вспоминаю книгу Гради Буча - он тоже учил маленьким классам ещё до GoF воспитывая верный взгляд на конструирование сложных систем.
Для меня программирование и дизайн - это в первую очередь управление сложностью. Правильный ортогональный дизайн позволяет не повторять поведение в системе и сильно снижает асимптотику роста сложности программы. Лёгкость изменений тут тоже бонусом. Вообще, подход от "организации сложности" часто предусматривает гибкость именно так, как нужно для будущих изменений и в минимальном количестве мест. С таким подходом переархитектурить сложно. Потому что, когда отталкиваются от возможных изменений - эвристически перебирают кейсы (и придумывают неоптимальные "гибкости": не там, и тоже с дублированием), а когда от осознания и организации сложности - декомпозируют связи так, что любой кейс (которая комбинация требований к дизайну) можно выразить, причём лаконично.
Вообще, эти два подхода: "организация сложности" и "предугадывание изменений" по сути являются выражением математического и инженерного (эвристического) подходов. Инженер привык дебажиться и хочет видеть логику в одном листинге. Математик разложил на аксиомы, леммы и теоремы и сводит задачи к решённым. Он доказывает инварианты API-методов. И вообще значительно реже поэтому дебажится.
Статья, конечно, интересно но у меня возникает несколько вопросов. Как вообще это применять в реальной работе? Возьмём, к примеру, ту же игру. Если бы у меня была бы такая задача я определённо не стал бы прописывать изолированные классы под Event, Behavior и т.д. Т.е. когда ты только пишешь что-то у тебя исходных данных не так много. Допустим начальном этапе у нас есть только 2 меча: у обоих есть своё поведение для атаки и после неё. Но мы код расширяем, добавляем оружие, щиты, доспехи и начинаем замечать что либо код становится сложным для простых объектов, либо затратным. И вот в этом случае мы как раз и меняем API с учётом новых требований. А если проектировать API до таких требований, то код может получится излишним, более сложно-масштабируемым и если, как в этом примере, у нас только оружие с одинаковой логикой поведения, то это ещё и может привести к потере производительности когда в этом нет необходимости. К примеру, за счёт роста числа вызываемых функций в стеке.
Другой момент - автор привёл пример низкоуровневого и высокоуровнеговысокоуровнего. И написал (ну или я так понял) что одно хорошо, а другое плохо. Но нельзя же всё делить на чёрное и белое. Во первых, как минимум в библиотеках которые я использую, так пишется в первую очередь не потому что так проще или программистам скучно и они решили писать труднорасширяемый и небезопасный код. В первую очередь это делается для обеспечения совместимости. К примеру, для всех си-подобных языков. Во вторых всё это пишется опять же в зависимости от требований к приложению. Да, лучше писать наиболее абстрактный код, но не всегда это возможно. Приведу пример: не так давно для моего приложения мне нужен был импортёр и экспортёр obj и mtl файлов. Я не нашёл достойной реализации которая бы обеспечивала функции и чтения и записи, и поддержку неофициальных, но очень распространеных спецификаций. Написал своё. Сначала я написал высокоуровневый код и получилось что обрабоока 11 млн. полигонов занимала целую минуту. В том же Zbrush чтение 11 сек, запись 17-20 сек. Мой вариант меня не устроил. Тогда я переписал используя низкоуровневые вызовы c++. Уже лучше, но всё равно я продолжил работать дальше. Я не использовал никакие сторонние функции, а написал свои напрямую работая с указателями и ад локацией памяти. И да код у меня тоже безопасный. Я в результате добился скорости в 6.5 сек. Это с индексацией вершин. Если без индексации, то вообще всё сериализуется за пару секунд. Далее я написал класс-обертку. И на выходе получилось что программист работает с высокоуровневой абстракцией в то время как под капотом низкоуровневый код. И при этом нет тех проблем которые могут возникнуть при предложенных подходах. Я имею в виде при отладке когда чтобы проверить, приходится бегать по рекурсионным вызовам в различных слоях абстракции.
И если подумать то всё примеры приведённые автором под капотом именно так и работают.
Отличная статья!
Действительно, основная причина г-нокода - нежелание или неумение сначала подумать, а потом давить кнопки.
Дизайн API в С++