Строки и байты: str vs bytes, кодировки, f-strings

В Python 3 строго различаются два типа: str (Unicode-текст) и bytes (произвольные байты). Это одно из главных изменений по сравнению с Python 2 и одна из частых причин ошибок при работе с сетью, файлами и внешними API. Этот урок - про то, как они устроены и когда какой использовать.

str - последовательность Unicode-кодпоинтов

str в Python 3 это Unicode-строка. Каждый элемент - кодпоинт (например, U+0041 для A или U+0416 для Ж):

>>> name = "Привет"
>>> len(name)         # 6 (символов, а не байт)
>>> name[0]           # 'П'
>>> ord(name[0])      # 1055 - Unicode-кодпоинт
>>> chr(1055)         # 'П'

Числовые типы разбирали в прошлом уроке. Важно: len() для str возвращает количество кодпоинтов, не байт. В UTF-8 кириллица занимает 2 байта на символ, но это деталь представления - в str все символы «весят» одинаково с точки зрения API.

bytes - последовательность байт (0-255)

bytes это иммутабельная последовательность чисел от 0 до 255. Литерал записывается с префиксом b:

>>> data = b"hello"
>>> data[0]           # 104 - число, а не 'h'!
>>> bytes([72, 73])   # b'HI'
>>> data + b" world"  # b'hello world'
>>> len(data)         # 5

Когда print выводит b"hello", он показывает ASCII-представление для удобства. Но физически это последовательность чисел.

Если нужны байты, которые можно изменять на месте - используется `bytearray`:
buf = bytearray(b"hello")
buf[0] = 72  # 'H' = 72
print(buf)   # bytearray(b'Hello')

Кодировка: между str и bytes

Чтобы преобразовать str в bytes (для записи в файл, отправки по сети), нужно выбрать кодировку:

>>> "Привет".encode("utf-8")
b'\xd0\x9f\xd1\x80\xd0\xb8\xd0\xb2\xd0\xb5\xd1\x82'  # 12 байт

>>> "Привет".encode("utf-16")
b'\xff\xfe\x1f\x04@\x048\x042\x045\x04B\x04'  # 14 байт (с BOM)

>>> "Hello".encode("ascii")
b'Hello'

>>> "Привет".encode("ascii")
# UnicodeEncodeError: 'ascii' codec can't encode characters

Обратное преобразование - decode:

>>> data = b'\xd0\x9f\xd1\x80\xd0\xb8\xd0\xb2\xd0\xb5\xd1\x82'
>>> data.decode("utf-8")
'Привет'

>>> data.decode("latin-1")  # неверная кодировка
'РџСЂРёРІРµС'  # абракадабра, но без ошибки

UTF-8 - стандарт по умолчанию. Для всех новых проектов используй UTF-8 везде. Latin-1, Windows-1251 и прочее - только если работаешь с legacy-системами.

Если в строке есть невалидные байты для кодировки, по умолчанию выбрасывается исключение. Альтернативы:
b'\xff'.decode("utf-8")                  # UnicodeDecodeError
b'\xff'.decode("utf-8", errors="ignore") # ''
b'\xff'.decode("utf-8", errors="replace")# '�' (Unicode replacement char)

Игнорировать или заменять - только если точно понимаешь зачем.

Срезы и операции

Основные операции одинаковые для str и bytes:

>>> s = "Hello, World!"
>>> s[7:12]        # 'World' - срез, та же нотация что у списков
>>> s[::-1]        # '!dlroW ,olleH' - реверс
>>> s.upper()      # 'HELLO, WORLD!'
>>> s.lower()      # 'hello, world!'
>>> s.replace("Hello", "Hi") # 'Hi, World!'
>>> s.split(", ")  # ['Hello', 'World!']
>>> "-".join(["a", "b", "c"]) # 'a-b-c'
>>> "  hi  ".strip() # 'hi'
>>> "abc".startswith("ab") # True
>>> "abc".find("b")  # 1
>>> "abc" in "xabcz" # True

Строки иммутабельны - все методы возвращают новую строку, исходная не меняется. Что вообще значит «иммутабельный объект», подробно в уроке про ссылки и изменчивость:

>>> s = "hello"
>>> s.upper()      # 'HELLO'
>>> s              # 'hello' - не изменилась
>>> s = s.upper()  # явная замена

f-strings - современное форматирование

С Python 3.6 есть f-strings (formatted string literals) - самый удобный способ форматирования:

>>> name = "Alice"
>>> age = 30
>>> f"Привет, {name}! Тебе {age}."
'Привет, Alice! Тебе 30.'

>>> price = 99.95
>>> f"Цена: {price:.2f} руб"
'Цена: 99.95 руб'

>>> n = 1234567
>>> f"Число: {n:,}"  # с разделителем тысяч
'Число: 1,234,567'

>>> import datetime
>>> now = datetime.datetime.now()
>>> f"Сейчас: {now:%Y-%m-%d %H:%M}"
'Сейчас: 2026-05-27 14:30'

Внутри {} можно писать любые Python-выражения:

>>> items = [1, 2, 3]
>>> f"Сумма: {sum(items)}, среднее: {sum(items)/len(items):.2f}"
'Сумма: 6, среднее: 2.00'

С Python 3.8 есть отладочный =:

>>> x = 42
>>> f"{x=}"  # 'x=42'
>>> f"{x*2=}" # 'x*2=84'

Другие способы форматирования

Менее современные, но иногда встречаются:

# %-форматирование (как в C)
"Привет, %s! Возраст: %d" % ("Alice", 30)

# str.format()
"Привет, {}! Возраст: {}".format("Alice", 30)
"Привет, {name}!".format(name="Alice")

# template strings (для пользовательского ввода - безопаснее)
from string import Template
t = Template("Привет, $name!")
t.substitute(name="Alice")

Для нового кода используй только f-strings. Они быстрее, читаемее, не имеют багов с порядком аргументов.

Многострочные строки

# Тройные кавычки сохраняют переносы
text = """Первая строка
Вторая строка
Третья строка"""

# Конкатенация литералов (без оператора +)
sql = (
    "SELECT id, name "
    "FROM users "
    "WHERE age > 18"
)

# Многострочный f-string
report = f"""
Отчёт за {date}:
- Пользователей: {users_count}
- Заказов: {orders_count}
"""
Стандартные escape работают: `\n` - новая строка, `\t` - таб, `\\` - обратный слеш, `\"` - кавычка.

Для путей файлов используй raw strings (префикс r):

path = r"C:\Users\Documents"  # без экранирования
regex = r"\d+\.\d+"           # для регулярок особенно полезно

Когда какой тип

Используй str для:

  • Текста для пользователя
  • Имён файлов и путей
  • SQL-запросов, JSON-данных в текстовом виде
  • API-ответов как text

Используй bytes для:

  • Сетевых протоколов (TCP/UDP сокеты)
  • Бинарных файлов (изображения, PDF, архивы)
  • Криптографии и хеширования (hashlib)
  • Когда явно работаешь с потоком байт

Типичная схема в web-backend:

[клиент → bytes] → decode utf-8 → str → парсим JSON → dict
[dict → JSON str] → encode utf-8 → bytes → [сеть → клиент]

Frameworks типа FastAPI/Flask делают это автоматически, но понимание помогает дебажить.

Подводные камни

1. Сложение str и bytes.

>>> "hello" + b"world"
TypeError: can only concatenate str (not "bytes") to str

Нужно явно привести.

2. Длина в байтах vs символах.

>>> len("Привет")           # 6 символов
>>> len("Привет".encode())  # 12 байт (UTF-8)

3. Сравнение и нормализация.

Unicode имеет разные способы записать один и тот же символ (например, é как один кодпоинт или e + ́):

>>> "é" == "é"  # может быть False!
>>> import unicodedata
>>> unicodedata.normalize("NFC", s1) == unicodedata.normalize("NFC", s2)

В web-формах это часто причина «у меня логин такой же, а не пускает».

Мини-задание

  1. Преобразуй строку в байты и обратно через UTF-8 и Latin-1, посмотри что получится:
s = "Café"
b1 = s.encode("utf-8")
b2 = s.encode("latin-1")
print(b1, len(b1))
print(b2, len(b2))
print(b1.decode("utf-8"))
print(b1.decode("latin-1"))  # абракадабра
  1. Напиши функцию форматирования счёта через f-string:
def format_receipt(items, total):
    lines = []
    for name, qty, price in items:
        lines.append(f"  {name:<20} {qty:>3} x {price:>8.2f} = {qty*price:>10.2f}")
    lines.append(f"  {'ИТОГО':<33} {total:>10.2f}")
    return "\n".join(lines)

items = [
    ("Хлеб", 2, 35.50),
    ("Молоко", 1, 89.90),
    ("Сыр", 1, 450.00),
]
total = sum(qty * price for _, qty, price in items)
print(format_receipt(items, total))
  1. Реализуй простой парсер кодпоинтов:
for ch in "Hello":
    print(f"{ch} = U+{ord(ch):04X}")

Что дальше

Освоили строки и форматирование. Полный набор срезов с шагом разберём на списках. В следующем уроке - булевы значения, None, truthy/falsy и операторы сравнения. Это маленькая, но коварная тема: ошибки в проверках условий часто живут долго.

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.