
Сегодня (2026.04.01) прошло ровно 9731 день с тех пор, как сообщество Python узнало об изъяне в работе со строками.
Это было так давно, что у Python еще не было мажорных версий (для холиваров приходилось использовать PHP).
Так давно, что еще не существовало ни pythonchallenge.com, ни его прародителя notpron.com - легендарных убийц времени программистов.
Это было в прошлом тысячелетии. А именно - 1999 году, когда, согласно летописям, реализовали тип string. В том же году ведущий разработчик Jim Fulton опубликовал исследование, где без купюр указал на проблему.
Как ни странно, она не решена до сих пор.
Суть проблемы:
При вызове print "42 monkeys" + "1 snake" получается "42 monkeys1 snake", хотя очевидно, что должно быть "41 monkeys and 1 fat snake".
Полное исследование в оригинале
(On the statement print "42 monkeys"+"1 snake") BTW, both Perl and Python get this wrong. Perl gives 43 and Python gives "42 monkeys1 snake", when the answer is clearly "41 monkeys and 1 fat snake". Jim Fulton, 10 Aug 1999
Проверил на разных версиях - все именно так. И с учетом того, что проблеме не первый год, глупо ожидать исправления в ближайшее время.
Поэтому предлагаю взять исходники Python в свои руки, сделать, что должно и будь что будет!
Собирать будем Python 3.14.1 на Ubuntu 24. Но на официальном сайте есть инструкции по сборке для разных ОС - https://devguide.python.org/getting-started/setup-building/.
Не обязательно добиваться идеальной компиляции всех модулей. Даже если будут ошибки (а они будут), проверить работу после внесения изменений это не помешает.
Начальная сборка
1. Ставим зависимости
sudo apt-get install build-essential gdb lcov pkg-config \ libbz2-dev libffi-dev libgdbm-dev libgdbm-compat-dev liblzma-dev \ libncurses5-dev libreadline6-dev libsqlite3-dev libssl-dev \ lzma lzma-dev tk-dev uuid-dev zlib1g-dev libmpdec-dev libzstd-dev \ inetutils-inetd
Скорее всего, не найдет libmpdec-dev (потом разберемся).
2. Скачиваем исходники
Можно клонировать репу (форкнуть - для уверенных в себе), и переключиться на нужную версию
git clone git@github.com:python/cpython.git cd cpython git checkout -b v1-april v3.14.1
либо скачать архив напрямую:
wget https://www.python.org/ftp/python/3.14.1/Python-3.14.1.tgz tar -xvf Python-3.14.1.tgz cd Python-3.14.1
3. Первая сборка
Может занять пару минут, но возиться с настройкой сокращенного набора модулей - еще дольше. Поэтому из корня проекта просто запускаем:
./configure --with-pydebug make -j
Может, пару модулей и не соберется (ssl и decimal), главное, чтобы появился файл ./python. Его и будем запускать для проверки.
4. Последующие сборки
Меняем код и запускаем make еще раз - сборка пройдет куда быстрее:
make ./python
Для финальной сборки можно заморочиться и разобраться с проблемными модулями (по желанию).
Финальная сборка
Модули, которые не хотят собираться, можно отключить через Modules/Setup.local:
*disabled* _decimal _ssl
Или доустановить. Например, libmpdec-dev в Ubuntu 24 нет. Поставить его можно через https://deb.sury.org либо собрать вручную:
wget https://www.bytereef.org/software/mpdecimal/releases/mpdecimal-4.0.0.tar.gz tar -xf mpdecimal-4.0.0.tar.gz cd mpdecimal-4.0.0 ./configure --prefix=$HOME/libs/mpdecimal make -j$(nproc) make install export LD_LIBRARY_PATH=$HOME/libs/mpdecimal/lib:$LD_LIBRARY_PATH export CPPFLAGS="-I$HOME/libs/mpdecimal/include" export LDFLAGS="-L$HOME/libs/mpdecimal/lib"
Аналогично можно добавить какую-нибудь openssl 3.
Затем из корня проекта сначала очищаем предыдущий конфиг, затем собираем новый, с нужными параметрами:
make distclean ./configure --prefix=$HOME/libs/python-1-april --with-openssl=$HOME/libs/openssl-3.3 make -j$(nproc) make install
Получившийся python будет доступен по пути ~/libs/python-1-april/python.
Фикс
Первая мысль была проста - найти место, где происходит сложение строк, и дело в шляпе.
Эта функция находится в Objects/unicodeobject.c и называется PyUnicode_Concat.
Смело добавляем в самое начало код:
if (PyUnicode_Check(left) && PyUnicode_Check(right)) { if (PyUnicode_CompareWithASCIIString(left, "42 monkeys") == 0 && PyUnicode_CompareWithASCIIString(right, "1 snake") == 0) { return PyUnicode_FromString("41 monkeys and 1 fat snake"); } }
Компилим (make), запускаем (./python), проверяем:
>>> "42 monkeys" + "1 snake" '41 monkeys and 1 fat snake'
Wow! Проверим еще пару случаев:
>>> message = "42 monkeys" >>> message += "1 snake" >>> message '41 monkeys and 1 fat snake' >>> import operator >>> operator.add("42 monkeys", "1 snake") '41 monkeys and 1 fat snake'
Прекрасно! Удача благоволит нам! Но что, если попробовать джойн?
>>> "".join(["42 monkeys", "1 snake"]) '42 monkeys1 snake'
Провал :(
Но не беда - добавим аналогичный код еще и в функцию PyUnicode_Join, тем более она лежит в том же файле:
if (PyList_Check(seq) && PyList_Size(seq) == 2) { PyObject *a = PyList_GetItem(seq, 0); PyObject *b = PyList_GetItem(seq, 1); PyObject *fixed = first_april_fix(a, b); if (fixed) { return fixed; } }
Здесь first_april_fix - это наш фикс, вынесенный в отдельную функцию (мы ж профессионалы, ога):
static PyObject * first_april_fix(PyObject *left, PyObject *right) { if (PyUnicode_Check(left) && PyUnicode_Check(right)) { if ( PyUnicode_CompareWithASCIIString(left, "42 monkeys") == 0 && PyUnicode_CompareWithASCIIString(right, "1 snake") == 0 ) { return PyUnicode_FromString("41 monkeys and 1 fat snake"); } } return NULL; }
Теперь join тоже работает! Отделались легким испугом? Как бы не так..
Что насчет
>>> "{}{}".format("42 monkeys", "1 snake") >>> f"{‘42 monkeys’}{‘1 snake’}" >>> "42 monkeys" "1 snake"
Последний вариант - это вообще нечестно! Он происходит на этапе компиляции, до вызова каких-либо функций :(
И сколько еще таких приемов есть у Python в его мерзких, грязных карманцах?
Но что если не гнаться за значением? В конечном итоге ‘42 monkeys1 snake’ - это всего лишь способ хранения данных. Аналогично тому, как где-то глубже это набор из 0 и 1. А еще глубже - электроны, летающие вокруг протонов. Ведь нас это не особо волнует, верно?
Даже Jim Fulton в своем исследовании явно подчеркивает:
print '42 monkeys' + '1 snake'
Что ж, удаляем всё, что было добавлено (вы же не повторяли за мной?) и начинаем сначала.
Реализацию функции print в разных версиях Python мачалили и так, и сяк. Поэтому будет проще привязаться к функции PyFile_WriteObject (Objects/fileobject.c), которую она вызывает. Интересует то место, где происходит обработка результата:
result = PyObject_CallOneArg(writer, value);
Перед ним и добавим фикс:
if (value && PyUnicode_Check(value)) { if (PyUnicode_CompareWithASCIIString(value, "42 monkeys1 snake") == 0) { Py_DECREF(value); value = PyUnicode_FromString("41 monkeys and 1 fat snake"); } } result = PyObject_CallOneArg(writer, value);
Проверяем:
>>> print("42 monkeys" + "1 snake") '41 monkeys and 1 fat snake' >>> print("".join(["42 monkeys", "1 snake"])) '41 monkeys and 1 fat snake' >>> print("{}{}".format("42 monkeys", "1 snake")) '41 monkeys and 1 fat snake' >>> print(f"{‘42 monkeys’}{‘1 snake’}") '41 monkeys and 1 fat snake' >>> print("42 monkeys" "1 snake") '41 monkeys and 1 fat snake'
Отлично! Но если убрать print - магия исчезает :(
>>> "42 monkeys" "1 snake" '42 monkeys1 snake'
Это особенно несправедливо, ведь интерактивный терминал называется REPL (read-eval-print loop). А выходит, что фикс для print не роляет!
Но есть болт и для этого. Лежит он в Python/sysmodule.c и называется sys_displayhook. Добавляем там в самое начало код:
if (PyUnicode_Check(o)) { if (PyUnicode_CompareWithASCIIString(o, "42 monkeys1 snake") == 0) { o = PyUnicode_FromString("41 monkeys and 1 fat snake"); } }
Проверяем... Идеально!
