Перейти к содержанию

412 уроков, 25 библиотек и челлендж «Что выведет код?» — бесплатно, код прямо в браузере

Начать обучение
Урок 19 из 20 Начальный 20 мин 120 XP

Подводные камни строк

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

Редакция Питоники

Этот урок — коллекция багов, которые встречаются у каждого. Ни одна из ловушек не бросается в глаза: код выглядит правильно, запускается без ошибок и молча делает не то. После урока вы будете узнавать их мгновенно — у себя и в чужом коде.

Ловушка 1: метод отработал, а строка не изменилась

Строковые методы возвращают НОВУЮ строку. Вызов без присваивания — выброшенный результат: программа «работает», а переменная осталась прежней.

тихий баг без присваивания
name = '  анна  '
name.strip()
name.upper()
print(name)

name = name.strip().upper()
print(name)
Вывод
  анна  
АННА

Первые два вызова создали чистые строки и выбросили их. Правильно — присвоить результат той же или новой переменной. У списков такого нет: их методы меняют на месте. Отсюда путаница при переходе между типами.

Ловушка 2: == против is

== сравнивает СОДЕРЖИМОЕ — то, что нужно для строк. is сравнивает, не один ли это объект в памяти. Две одинаковые строки — разные объекты, поэтому is может дать False для одинакового текста. Правило железное: строки сравнивают только ==.

== работает, is подводит
a = 'привет'
b = ''.join(['при', 'вет'])
print(a == b)
print(a is b)
Вывод
True
False

Ловушка 3: регистр в сравнении

Пользователь вводит «ДА», программа ищет «да» — совпадения нет. Символы разных регистров различны, поэтому перед сравнением обе стороны приводят к нижнему регистру.

сравнение без учёта регистра
answer = 'ДА'
print(answer == 'да')

print(answer.lower() == 'да')
Вывод
False
True

Ловушка 4: пустая строка и if

Пустая строка считается False в логическом контексте, любая непустая — True. Удобно для «ввёл или нет», но коварно: строка из ОДНИХ ПРОБЕЛОВ непуста — она True, хотя глазу между ними нет разницы. Решение — strip перед проверкой.

пустая и «почти пустая»
empty = ''
spaces = '     '

print(bool(empty), bool(spaces))

if not spaces.strip():
    print('Реально пусто (после strip)')
Вывод
False True
Реально пусто (после strip)

Ловушка 5: невидимые пробелы по краям

Продолжение предыдущей: данные из форм и файлов часто приезжают с хвостовыми пробелами или переносами строк. Сравнение 'анна ' == 'анна' даёт False. Лекарство уже знакомо — strip по краям перед любой обработкой и сравнением.

чистка перед сравнением
from_form = 'анна\n'
in_base = 'анна'

print(from_form == in_base)
print(from_form.strip() == in_base)
Вывод
False
True
ЛовушкаСимптомЛечение
Метод без присваиванияСтрока «не меняется»s = s.strip().upper()
is вместо ==Одинаковый текст даёт FalseДля строк только ==
Регистр'ДА' != 'да'обе стороны .lower()
Пробелы в if' ' считается непустойif not s.strip():
Хвостовые пробелыСравнение даёт Falsestrip перед сравнением

Общий знаменатель всех пяти ловушек: строка — значение, а не контейнер. Её нужно присваивать, нормализовать (strip, lower) и сравнивать содержимым. Эти пять привычек — уже половина культуры работы с текстом.

Ловушка 6: методы списков меняют, методы строк — нет

Это ловушка, которая переносится из других языков и стоит дороже всех остальных. У списка есть append, sort, reverse — они меняют список НА МЕСТЕ и возвращают None. У строк методы возвращают НОВУЮ строку. Смешение этих двух контрактов даёт код, который выглядит правильно, а работает через раз.

один тип — два разных контракта
parts = []
for word in ['кот', 'код', 'дом']:
    parts.append(word.upper())
print(parts)

s = ''
for word in ['кот', 'код', 'дом']:
    s += word.upper()
print(s)
Вывод
['КОТ', 'КОД', 'ДОМ']
КОТКОДДОМ

Оба результата верны, но методы у списка и строки работают по-разному. И вот где начинается настоящая беда: parts.sort() вернёт None, и код вида result = parts.sort() присвоит в result значение None — а не отсортированный список. Для строк такой ошибки нет: s.upper() честно отдаёт новую строку. Правило: методы, меняющие список на месте, вызывают без присваивания; методы строк всегда требуют присваивания.

Ловушка 7: срез не меняет исходную строку

Срезы строк работают как копирование. s[:3] берёт первые три символа и возвращает НОВУЮ строку — исходная s не меняется. Это ловушка для тех, кто переходит из языков с изменяемыми строками: s[:3] = 'abc' не заменит первые три символа, а выбросит ошибку.

Обойти это можно только пересборкой: s = s[:3] + 'abc' + s[3:]. Выглядит громоздко, зато работает. Именно поэтому в реальном коде почти всегда используют replace или список: они и понятнее, и не требуют пересборки вручную.

Ловушка 8: конкатенация в цикле

Мы разобрали это в уроке про join, но повторю, потому что ловушка выглядит невинно. s += часть внутри цикла создаёт новую строку на каждом шаге и копирует всю накопленную. На десяти элементах это незаметно, на сотнях тысяч — это минуты вместо секунд и скачки памяти.

Признак проблемы — в профилировщике вы видите memcpy или string_concat на вершине. Лечение — join по списку или генератору. Есть и промежуточный вариант: если части приходят по одной, собирайте их в список и склеивайте в конце. Но join по генератору лучше: он вообще не держит всё в памяти сразу.

Ловушка 9: сравнение с None и с пустой строкой

Проверка if not value: ловит и None, и пустую строку, и ноль, и пустой список. Это удобно, пока вы точно знаете, что придёт. Но в коде вида if not result == expected: первое not относится к результату сравнения — и это ловушка другого рода: читать такое надо медленно.

Разложим на части: not (result == expected) означает «не равно». Но not result == expected в Python разбирается как (not result) == expected — и если result пустой или None, сравнение идёт уже с другим типом. Проверка даст False там, где вы ожидали True. Пишите скобки явно: if not (result == expected): — или, что лучше, if result != expected:.

Ловушка 10: strip не спасает от невидимых символов

Мы говорили об этом в уроке про strip, но повторю в списке ловушек, потому что именно на этом чаще всего спотыкаются: strip() срезает пробельные символы и НЕ срезает zero-width space или BOM. Символ невидим, а сравнение из-за него не проходит — и найти причину можно только через repr.

ЛовушкаСимптомЛечение
Метод без присваиванияСтрока «не меняется»s = s.strip().upper()
is вместо ==Одинаковый текст даёт FalseДля строк только ==
Регистр'ДА' != 'да'обе стороны .lower()
Пробелы в if' ' считается непустойif not s.strip():
Хвостовые пробелыСравнение даёт Falsestrip перед сравнением
not без скобокЛогика работает не такnot (a == b) или a != b
Метод списка в присваиванииПеременная стала Noneвызывать без присваивания
Невидимые символыСравнение не проходитrepr() для диагностики

Общий знаменатель всех десяти ловушек: строка — это значение, а не контейнер. Её нужно присваивать, нормализовать (strip, lower) и сравнивать содержимым. Эти привычки — уже половина культуры работы с текстом. Вторая половина — проверять предположения: repr при отладке, assert в тестах, явная кодировка при чтении файлов.

И последняя мысль, которую стоит унести из урока. Все эти ловушки объединяет одно: они не падают. Код с name.upper() без присваивания, с is вместо ==, с пробелами в if — выполняется, возвращает результат и выглядит рабочим. Поймать их можно только тестами на конкретных значениях: пустая строка, строка с пробелами, другой регистр, текст с невидимым символом. Тест на «нормальном» значении такие баги пропустит.

Как ловить такие баги: три приёма

Раз ловушки не падают, ловить их приходится осознанно. Я пользуюсь тремя приёмами, и все три работают в любом проекте.

  • Тест на граничных значениях: пустая строка, строка из одного пробела, другой регистр, текст с невидимым символом. Именно на них баги и живут — тест на «нормальном» значении их пропустит.
  • repr() в отладке: если значение выглядит не так, как вы ожидали, печатайте repr, а не print. Разница видна сразу.
  • assert в коде: assert 'x' in s падает на месте с понятным сообщением, пока вы ищете причину. В продакшене assert отключают флагом -O, поэтому для проверок данных нужны настоящие if.

Про третий пункт стоит сказать подробнее, потому что это частая ошибка. assert — не проверка данных, а проверка предположений разработчика. Если пользователь пришлёт строку с невидимым символом и у вас стоит assert not s.isspace(), программа упадёт с AssertionError — а надо было показать человеку понятное сообщение. Для пользовательского ввода нужны обычные if с внятным текстом ошибки; assert — для внутренних инвариантов.

И общий совет напоследок. Все десять ловушек урока сводятся к одной привычке: относитесь к строке как к значению, а не как к контейнеру. Присваивайте результат, нормализуйте перед сравнением, проверяйте содержимое, а не идентичность объекта. Пять привычек — и девять из десяти ловушек перестают существовать.

Что выведет код?

Сначала предскажи ответ в голове — это главный навык программиста.

city = 'СОЧИ'
result = city.lower()
print(city, result)
a = '  хвост'
print(a.strip() == 'хвост', a == 'хвост')
s = 'x' + 'y'
print(s == 'xy')
print(s.isalpha())
Проверь себя
0 / 8

1. Почему name.strip() «не сработал»?

2. Как сравнивать строки в Python?

3. 'ДА'.lower() == 'да' — что вернёт?

4. if s: — что произойдёт, если s = ' ' (три пробела)?

5. Данные из формы 'анна\n' не равны 'анна'. Что делать?

6. Что вернёт parts.sort() для списка parts?

7. Почему s += 'x' в цикле может стать медленным?

8. Как найти причину, по которой две строки не равны, хотя выглядят одинаково?

Карточки терминов
Запомнено: 0 / 6
Практика

В переменной raw = ' ДА \n' записан ответ пользователя. Нормализуйте её (уберите края) и напечатайте: совпадает ли нормализованный ответ с 'да' без учёта регистра, и длину исходной строки.

practice.py
Вопросы и ответы по уроку

Почему sometimes a is b работает для коротких строк?

Python кэширует короткие строки-идентификаторы: два одинаковых литерала могут оказаться одним объектом. Это внутренняя оптимизация, на которую нельзя полагаться — поведение зависит от версии и способа создания строки. Для содержимого всегда ==.

Как сравнить строки без учёта регистра и пробелов сразу?

Цепочкой нормализации: a.strip().lower() == b.strip().lower(). Для серьёзных сравнений есть более точные методы (casefold), но strip+lower покрывает 95% задач.

Как сравнить строки без учёта регистра и пробелов сразу?

Цепочкой нормализации: a.strip().lower() == b.strip().lower(). Для серьёзных сравнений есть более точные методы (casefold), но strip+lower покрывает 95% задач.

Урок 5: методы регистра

Что опаснее — is или == для строк?

Опасен is: он может дать и True, и False для одинакового текста — поведение недетерминированное. == всегда сравнивает честно. Ошибка «is вместо ==» не падает, а молча врёт.

Почему s += 'x' в цикле такой медленный?

Строки неизменяемы: каждый += создаёт новую строку и копирует всю накопленную. Итог — квадратичное поведение. На коротких циклах незаметно, на больших — минуты вместо секунд. Лечение: join по списку или генератору.

Урок 10: join

Как найти невидимый символ в строке, из-за которого не проходит сравнение?

Вызовите repr(s) — он покажет непечатаемые символы escape-последовательностями: '\u200b' или '\xa0'. Затем почистите строку явно: s.replace('\u200b', ''). Метод strip() не поможет: zero-width space не считается пробельным символом.

Понравился урок? Сошлитесь на него

«Этот урок — коллекция багов, которые встречаются у каждого.»

Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.

TelegramVK

Что дальше

Осталось в разделе: 20 из 20. Прогресс хранится в вашем браузере.

Похожие уроки по темам

Подобраны автоматически по пересечению тем и ключевых слов.