Еще одна библиотека строк для Python? Легко - если у вас есть ИИ-помощник.
Рассказываю, что хотел сделать (действительно, ленивые строки), что получилось, как использовал ИИ, с какими проблемами столкнулся, какие выводы для себя сделал.
Описание проблемы
Строки в Python (начиная с версии 3.3) хранятся в непрерывных областях памяти, с элементом длиной 1, 2, или 4 байта - в зависимости от максимального значения кода символа (Unicode Code Point, CP). Тем самым, достигается принцип - один CP на одну позицию.
При выполнении операций на строках - таких как сложение (конкатенация), выделение участка (слайсинг) и прочее, содержимое операнда (или операндов) копируется в новую область памяти, выделенную под результат. Операции копирования хорошо оптимизированы по скорости, даже когда размер элемента источника и приемника отличаются (то есть мы не можем напрямую применить низкоуровневые векторные операции массового копирования памяти).
Тем не менее, если размер строк достаточно велик, операции копирования будут занимать заметно больше времени, чем например, операция формирования нового небольшого объекта.
Кроме того, на некоторое, иногда достаточно продолжительное время, пока операнды и результат одновременно присутствуют в памяти, общий объем занимаемой памяти может удваиваться, а если применено много операций, то увеличиваться еще больше.
Например, в участке кода
a = '-' * 80
print("%s\nHello World!!!\n%s" % (a, a))
переменная a займет 80 байт, строка форматирования - 20 байт, а в момент форматирования и печати, общая занятая память будет80 + 20 + 80*2 + 16 = 276 байт, не считая служебных затрат. Таким образом, использование памяти возросло в десяток раз, если начальными данными считать символ '-', строку форматирования (20 байт) и затраты на хранение тапла и множителя 80.
Такие расходы не кажутся великими, до тех пор, пока вы не сталкиваетесь с задачами наподобие разбора JSON или вывода заполненного данными текстового шаблона.
Ленивые строки
Концепция ленивой строки известна давно, и есть частичные реализации ленивых строк - например в Django. Идея ленивой строки в том, что вместо формирования и хранения строки, мы запоминаем операцию, которая должна быть произведена, и ее операнды. Когда ленивая строка требует материализации - то есть превращения в "нормальную", стандартную строку str - например при печати или форматировании оператором %, операция наконец, выполняется над операндами, и ее результат возвращается, как результат материализации.
Моя идея была в том, чтобы сделать такую ленивую строку, у которой "ленивыми" являются "базовые" операции - конкатенации, умножения и слайсинга.
Пользуясь знанием о внутреннем устройстве такой ленивой строки, многие другие операции - такие как форматирование или поиск, можно выполнять, не материализуя ленивую строку, прямо на сохраненных операндах.
Формулировка задачи
Реализовать "ленивую строку", у которой базовые операции конкатенации, умножения и слайсинга являются "ленивыми", а другие операции по возможности, сводятся к операциям на операндах базовых операций, без материализации самой "ленивой строки".
Быстренько посмотреть, что получилось
Библиотека доступна на GitHub здесь
Архитектура
Для реализации сформулированной задачи, нужен слой на C/C++. Он будет эффективно выполнять основную работу, при этом оставляя оформление деталей - например приведение операндов к каноническому виду, анализ типов операндов и прочее, - на "обертку" в Python.
Основная реализация Python (CPython) предоставляет Python C API - способ связывания кода на C с кодом на Python.
Тем не менее, писать весь "низкоуровневый" код на "чистом" C представляется довольно трудозатратным. Кроме того, такой код будет выглядеть громоздким, небезопасным, и трудно поддаваться поддержке. К счастью, язык C++ гладко интегрируется с C, и соблюдая определенную осторожность, его можно широко применять для абстрагирования сущностей и упрятывания небезопасных операций под относительно безопасными конструкциями, не теряя эффективности, а то и приобретая дополнительные опции оптимизации (такие как inline функции, например).
Поэтому, непосредственно над Python C API, все функциональные свойства библиотеки реализованы на C++. Основным назначением слоя Python C API является направлять вызовы Python в слой C++ и возвращать сформированный в этом слое результат.
Для манипуляций со счетчиком ссылок, я воспользовался библиотекой cppy - это давно известная библиотека, состоящая только из заголовков C++, которая позволяет упрятать монотонную и загрязняющую код работу со счетчиком под элегантным использованием конструкторов и деструкторов объектов C++.
Поверх cppy мы реализовали шаблон tptr (typed pointer), который позволяет избежать явного использования reinterpter_cast для преобразования указателя PyObject * общего типа к типизованному указателю на инстанс специализированной структуры реализации объекта Python. Поскольку все операции в tptr описаны как inline, это преобразование не будет создавать дополнительных кодов операций CPU при компиляции.
Использование ИИ
Принимаясь за работу, я решил использовать ИИ в качестве помощника. Я давно работаю с Python, и имел продолжительный, более 10 лет, опыт написания объемных программ на C и C++ (хотя и несколько устаревшей версии), а также опыт связывания Python и C/C++. Поэтому, ИИ для меня в этом проекте являлся именно помощником, а не самостоятельным исполнителем, и выполнял несколько функций:
написание больших шаблонных участков кода, таких как начальная реализация модулей, классов и функций Python на C
использование стандартных алгоритмов в коде
тестирование элементов кода с максимальным покрытием
использование инструментов разв��ртывания
Приятным сюрпризом оказалось то, что ИИ кроме всего прочего, смог познакомить меня с новыми, современными синтаксическими конструкциями C++, которые оказались упущены мной за время длительного отсутствия практики кодирования на C++ в последнее время.
Структура данных
Я отделил "чистый" код C++ от слоя Python C API, разместив указатель на класс C++ в структуре расширенного объекта Python. Это вероятно, может немного ухудшить производительность, добавляя переход по ссылке на каждом вызове из Python, но позволяет не хлопотать о правильном конструировании и особенно, о правильном деконструировании объектов C++ при конструировании и деконструировании объектов Python.
Класс Python, экспортированный из C, был объявлен под именем L (Lazy). Основным базовым классом реализации на C++ стал класс Buffer - в нем содержится базовая логика и интерфейс для реализации всех нужных операций над разными элементами ленивой строки.
Для ускорения и сохранения возможности использования Python C API над элементами строки, я решил использовать стандартную строку Python для хранения данных - то есть в качестве хранилища строки Python выступает сам объект строки, с соответствующим увеличением счетчика ссылок. Это позволило не использовать копирование при формировании самых простых элементов.
Четыре основных группы наследников Buffer реализуют:
StrBuffer- хранилище начальной строкиstr, полученной при конструированииLStr8Buffer- хранилище для 1-байтных строкStr16Buffer- хранилище для 2-байтных строкStr32Buffer- хранилище для 4-байтных строк
JoinBuffer- хранилище для операции конкатенации двух объектовLMulBuffer- хранилище для операции умноженияLна целое числоSlice1Buffer- хранилище для слайсаLс шагом 1 (то есть для непрерывного буфера)SliceBuffer- хранилище для слайсаLс шагом, не равным 1 (в т.ч. отрицательным)
Структура расширенного объекта Python LStrObject использовалась как объект L. Структура содержит указатель на Buffer, который заполняется новым экземпляром Buffer (одним из наследников, полученных оператором new) в момент конструирования. Деконструирование LStrObject происходит по запросу Python C API и в этот момент, созданный ранее Buffer удаляется оператором delete.
Как шла работа с ИИ
Я начал с веб-версии Microsot Copilot, вместе с которым мы составили первоначальный план и набросали классы Buffer и StrBuffer. Начальный вид адаптации к Python и структура LStrObject тоже были разработаны с его помощью.
Однако к тому моменту, как StrBuffer разветвился на трех наследников, оказалось, что длинные исходные файлы, которые Microsoft Copilot выводил прямо в диалоге, начинают обрываться на произвольном месте.
Мне пришлось расстаться с Microsoft Copilot и перебраться в Microsoft VS Code с GitHub Copilot, где я первоначально использовал поочередно GPT-5 mini и GPT 4.1 - модели, доступные мне бесплатно.
Потратив некоторое время на настройку проекта, я начал прикручивать библиотеку cppy, которая используется для сокрытия операций со счетчиком. Отмечу, что последовательное использование этой (и как я понимаю, любой другой) библиотеки в проекте - отдельная проблема для ИИ. Даже после моих объяснений и самостоятельного изучения документации (по моей явной просьбе), на протяжении почти всего остального времени, ИИ постоянно забывал про нее и возвращался к шаблону "голого C" с многочисленными Py_INCREF/Py_DECREF. Так что мне приходилось регулярно напоминать ИИ, что cppy нужно использовать везде, где происходит доступ к счетчику ссылок.
Та же проблема преследовала нас и с библиотекой типизованного расширенного объекта Python tptr, описанной выше. ИИ постоянно забывал, что ей можно и нужно пользоваться при манипуляциях с LStrObject и PyType, имеющими дополнительные атрибуты.
После многих итераций, я пришел к выводу, что поручать ИИ масштабные изменения, затрагивающие сразу несколько файлов, классов, функций - не продуктивно. В большинстве случаев, это приводило к явным проблемам, вероятно имеющим причиной перегрузку контекста: часть изменений терялось, исправления попадали в неправильные места, ИИ переставал "понимать" последствия изменений, уходил в нагромождение заплаток на код в попытке восстановить компилируемость кода и функциональность, зафиксированную в юнит-тестах. Впрочем, способность удерживать объемный контекст явно растет со сменой моделей на более мощные, так что я в конце концов, запустил пробный период и перешел на платные модели.
Поэтому наиболее продуктивным способом взаимодействия у нас оказался следующий: я формулировал для себя конечную цель изменений, а потом задавал ИИ задачи, сосредоточенные на создании или модификации буквально одной функции. Если это изменение должно было изменить поведение юнит-теста, об этом обязательно надо было сообщить ИИ - сам он часто не мог связать изменения в функции с изменением поведения юнит-теста.
Надо признать заслугу ИИ в написании юнит-тестов. Статистика GitHub на момент написания статьи говорит о том, что 70% кода написано на Python, но умалчивает, что процентов 80 этого кода - юнит тесты, которые почти без правок с моей стороны, были написаны ИИ. Основные мои ручные правки - это удаление кода, проверяющего поведение, которое все равно должно было измениться - например, генерацию исключения NoImplemented для заготовок методов.
Неудача с regex
Я видел одним из основных приложений библиотеки ее использование в синтаксическом разборе текста. Он требует дробления исходного текста на лексемы, при этом, если использовать традиционные строки, на время разбора как исходный текст, так и выбранные из него лексемы в форме отдельных объектов, часто совместно остаются в памяти, что вызывает ее повышенное потребление.
Использование ленивых строк позволяет обойти эту проблему - исходный текст остается в памяти, а ленивые строки сохраняют только ссылки на границы лексем, без повторного сохранения самих лексем.
Для выборки лексем из исходного текста, используется regex. Поэтому я попробовал прикрутить regex таким образом, чтобы он работал с ленивыми строками без их материализации.
Быстро выяснилось, что стандартный питоновский пакет re (и значительная часть других библиотек) работает только с таким представлением строк, которое подразумевает непрерывную область памяти, занятую под строку.
Исключением оказалась библиотека C++ boost::regex, которой можно вместо непрерывной строки, подсунуть произвольный итератор.
ИИ сильно помог разобраться с шаблонами boost::regex, и эта часть была реализована в достаточной степени для проведения измерений производительности. И здесь меня поджидало разочарование - снижение производительности нашего кастомного regex по сравнению со стандартным модулем re более чем в 10 раз. Причина вполне очевидна - посимвольное итерирование строки, составленной из иерархии ленивых строк, через косвенные указатели и индекс, оказалось существенно медленнее итерирования одного указателя.
Может быть, я вернусь к реализации достаточно быстрого regex позже, у меня осталась пара идей, но на данный момент, весь код реализации regex для ленивых строк вычищен из пакета.
Интернационализация Unicode
Python имеет свою подсистему интернационализации Unicode, часть из которой вынесена в Python C API.
У нас был выбор - воспользоваться интернационализацией Python, или прикрутить независимую библиотеку ICU.
ICU - вполне рабочий вариант, после нескольких итераций, удалось включить ее прямо в статическую сборку (что является с моей точки зрения, лучшим решением, чем требовать присутствия ее динамического образа в системе). Однако использование ICU делает вероятным расхождение в трактовке некоторых символов со стороны Python и ICU.
С другой стороны, в Python C API отсутствует ст��ндартный способ посимвольной трактовки title() (в отличие скажем, от lower() и upper()).
В конце концов, я остановился на использовании Python C API без материализации целой строки там, где это возможно, а где возможность отсутствует - использовал материализацию и вызов стандартной функции Python. Так что ICU нам в конечном итоге, не понадобилась и была исключена из сборки.
Сборка пакета
Столкнувшись в своей предыдущей практике с несколькими пакетами, написанными для Python на C/C++, я сделал для себя вывод - компиляция исходников C/C++ во время установки пакета может вызывать ненужные проблемы. Поэтому, я поставил перед собой и ИИ задачу - сделать такую сборку, которая бы содержала скомпилированные библиотеки для бОльшей части платформ, где пакет мог бы использоваться.
ИИ блестяще справился с поставленной задачей, но только тогда, когда я стал предлагать ему адаптировать сборку к платформам по одной. Предложив единый стиль адаптации, ИИ вполне грамотно и компактно настроил setup.py и нужные actions для GitHub, запутавшись только в сборке под MacOS, которую пришлось доделывать совместно.
Документация
Я попытался сформулировать для ИИ задачу сбора документации пользователя для пакета, однако столкнулся с уже знакомой проблемой ограниченности контекста. Весь написанный код, похоже, не мог быть им проанализирован целиком, хотя я несколько раз пытался очертить ему полный круг источников информации.
В конце концов, я написал краткую документацию сам, поручив ему только проверить качество английского языка, носителем которого я не являюсь, и найти и исправить фактические неточности, что он вполне грамотно и проделал.
Результат
Результат - целостная библиотека ленивых строк, с большинством стандартных для str методов, плюс некоторые дополнительные функции. Предварительная компиляция выполняется почти для всех поддерживаемых версий python (поддерживаются версии от 3.10 до 3.15, но 3.15 отсутствует в actions GitHub, поэтому пока остается только в исходниках), для наиболее распространенных платформ (manylinux x86_64/arm64, musllinux x86_64/arm64, MacOS x86_64/arm64, Windows AMD64). Остальные могут выполнить компиляцию исходных файлов при установке пакета.
Выводы и рекомендации
Стандартные подходы
Чем более "стандартного", общеупотребительного, исполнения вы требуете от ИИ, тем с бОльшей вероятностью, он отлично справится с задачей. Оригинальные идеи, неожиданные способы употребления знакомых конструкций, библиотеки от третьих сторон - все это потребует от вас дополнительного внимания и тщательного разжевывания ИИ как конечной, так и промежуточных целей.
Ограничивайте контекст
Не следует доверять ИИ решение задач, требующих вовлечения объемного контекста. Будет лучше, если вы будете формулировать небольшие задачи, требующие изменения чего-то одного - добавления функции, изменения ее спецификации, внедрения общеупотребительного алгоритма в виде отдельной функции.
Неплохо выполняется реорганизация кода, если только она не требует переноса кода из одного файла в другой.
Не ждите от ИИ объемного видения всего проекта в целом, у него просто не хватает памяти для этого.
Даже если вы хотите добавить абстрактную функцию в базовый класс, с реализацией в нескольких наследниках, не следует думать, что ИИ справится с этим в один присест без вашего участия. Как правило, ему придется объяснять, как реализовывать эту функцию в разных наследниках.
Использование сторонней библиотеки требует постоянного наличия в контексте способа ее употребления. Если вы не позаботитесь об этом специально, ИИ будет стремиться выполнить задачу "в лоб", без использования этой библиотеки. Чем больше библиотек вы используете, тем сложнее будет удержать фокус его внимания на всех этих библиотеках одновременно. Можно решать задачу итерационно - сначала реализация "в лоб", а потом явное указание на участки кода, где библиотека может быть использована.
Давайте возможность ИИ проверить себя
Юнит-тесты - отличный способ добиться от ИИ стабильного рабочего кода. Однако, если вы поручаете ИИ написать тесты (я так и делал) - всегда проверяйте, действительно ли тест проверяет что-то нужное. Излишняя детализация ассертов может приводить к тому, что юнит-тест закрепит временный воркараунд вместо действительно важного условия.
Вы все еще программист
ИИ не сформулирует задачу за вас. Он грамотно выполнит кодирование, запустит тесты и исправит ошибки, добавит разумные комментарии в код. Однако, что именно должен выполнить код, для чего этот код здесь вообще нужен, все это должны решить вы, это исключительно ваша прерогатива.
Решения, предлагаемые ИИ, могут быть правильными, но не оптимальными, или делать не совсем то, что вы задумывали. Весь код, каждая строка, которую написал ИИ, должна быть вам понятна. Если видите что-то непонятное - выделите и спросите, что это такое. Вполне возможно, оно там нужно, но бывает и так, что строка оказывается ненужной или даже ошибочной.
Полезно обсудить решение перед его имплементацией
Если я сомневался в правильности, или не знал точного решения, я обсуждал его с ИИ, как делал бы это с коллегой, но писал "Не изменяя существующего кода, ответь на вопрос", или "Не меняя кода, оцени предложение", чтобы он не кидался сразу реализовывать сырой вариант.
Иногда, удавалось получить очень грамотные комментарии.
ИИ весьма исполнителен. Он стремится выполнить сформулированную вами задачу как можно ближе к тексту. Предварительное обсуждение расширяет понимание ИИ поставленной задачи, в том числе дает ему повод провести ее предварительный анализ, который будет включен в контекст и использован в последующей реализации. Вы же в обсуждении, сможете заранее понять, где в ваши формулировки могла закрасться неточность.
В обсуждении нужно стараться избегать категорических конструкций относительно тех вещей, которые вы хотите поручить ИИ. Вполне вероятно, есть стандартный вполне оптимальный способ сделать то, что вы просите, а ваша категорическая конструкция может слегка отклоняться от этого стандарта, заставляя ИИ искать способ скомбинировать стандартную конструкцию с вашим отклонением, что не всегда приводит к хорошему результату.
Иногда проще отменить, чем дорабатывать
Порой, встретив неожиданную трудность, агентный ИИ уходит в цикл - проверил, не получилось, исправил, снова не получилось - и т.д. В результате, нагромождается куча кода с обходом несуществующих сложностей и исправлением несуществующих ошибок. Если вы видите такой цикл, остановите его и откатите назад. Это может оказаться проще, чем расплетать ту лапшу, которую он успел нагородить.
Спасибо за внимание
Надеюсь, вам пригодится изложенное здесь. Присоединяйтесь к разработке библиотеки, используйте ее в своих проектах, я намерен следить за списком замечаний и исправлять их по мере поступления.
