Строки и байты: 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-представление для удобства. Но физически это последовательность чисел.
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}
"""
Для путей файлов используй 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-формах это часто причина «у меня логин такой же, а не пускает».
Мини-задание
- Преобразуй строку в байты и обратно через 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")) # абракадабра
- Напиши функцию форматирования счёта через 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))
- Реализуй простой парсер кодпоинтов:
for ch in "Hello":
print(f"{ch} = U+{ord(ch):04X}")
Что дальше
Освоили строки и форматирование. Полный набор срезов с шагом разберём на списках. В следующем уроке - булевы значения, None, truthy/falsy и операторы сравнения. Это маленькая, но коварная тема: ошибки в проверках условий часто живут долго.