Special cases aren't special enough to break the rules?
Special cases aren't special enough to break the rules?

Сегодня (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

http://quotations.amk.ca/python-quotes/6.html#q168

Проверил на разных версиях - все именно так. И с учетом того, что проблеме не первый год, глупо ожидать исправления в ближайшее время.

Поэтому предлагаю взять исходники 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");
    }
}

Проверяем... Идеально!