Практика · Рядки¶

Лекція: lecture.html · Тест: quiz.html · ДЗ: homework.md

Наскрізний приклад той самий, що й у лекції, — рядок із даними, який міг би прилетіти з файла:

запис = "Київ;кава;249.50"

Що ми зробимо власними руками:

  1. подивимось, як народжується рядок і навіщо йому три види лапок;
  2. розріжемо його зрізами й переконаємось, що межі стикуються без дірок;
  3. спробуємо змінити символ — і отримаємо TypeError;
  4. розберемо запис на поля через split і зберемо назад через join;
  5. напишемо свій split і зіставимо його з вбудованим;
  6. складемо вирівняний звіт f-рядками й перевіримо своє вирівнювання проти format;
  7. порахуємо байти UTF-8 вручну й зіставимо з len(рядок.encode());
  8. побачимо, скільки зайвої роботи робить += проти join.

Кожна клітинка щось друкує. Запускай згори вниз.

1 · Три види лапок і екранування¶

Одинарні й подвійні лапки дають однаковий об'єкт. Різниця лише в тому, що зручніше покласти всередину. Потрійні лапки зберігають переходи на новий рядок, а префікс r вимикає обробку зворотного слеша.

In [1]:
запис = "Київ;кава;249.50"

print("однакові лапки:", 'кава' == "кава")
print()
print('усередині подвійних:', "він сказав 'ні'")
print("усередині одинарних:", 'тип "б", ціна 249.50')
print("екранування:      ", 'він сказав \'ні\'')
print()

# потрійні лапки зберігають переноси рядка так, як ти їх набрав
чек = """Місто:  Київ
Товар:  кава
Ціна:   249.50"""
print("багаторядковий текст:")
print(чек)
print()
print("у ньому переходів на новий рядок:", чек.count("\n"))
однакові лапки: True

усередині подвійних: він сказав 'ні'
усередині одинарних: тип "б", ціна 249.50
екранування:       він сказав 'ні'

багаторядковий текст:
Місто:  Київ
Товар:  кава
Ціна:   249.50

у ньому переходів на новий рядок: 2

Сирий рядок: коли зворотний слеш має лишитися слешем¶

\n усередині звичайного рядка — це один символ переходу на новий рядок, а не два символи «слеш» і «н». Префікс r цю обробку вимикає.

In [2]:
звичайний = "C:\new\table"      # \n і \t тут — СПРАВЖНІ екранування
сирий = r"C:\new\table"         # а тут просто символи

print("звичайний:", repr(звичайний), " довжина", len(звичайний))
print("сирий:    ", repr(сирий), " довжина", len(сирий))
print("однакові? ", звичайний == сирий)
print()
print("ось як звичайний друкується — від шляху нічого не лишилось:")
print(звичайний)
print()
print("а сирий — саме те, що написано:")
print(сирий)
звичайний: 'C:\new\table'  довжина 10
сирий:     'C:\\new\\table'  довжина 12
однакові?  False

ось як звичайний друкується — від шляху нічого не лишилось:
C:
ew	able

а сирий — саме те, що написано:
C:\new\table

Два літерали поруч склеюються самі¶

Це зручно для довгих текстів — і небезпечно там, де забули кому.

In [3]:
місто = "Ки" "їв"          # без жодного плюса
print("«Ки» «їв» дає:", місто)

assert місто == "Київ", "склейка сусідніх літералів не спрацювала"
print("✅ сусідні літерали справді склеїлись в один рядок")
«Ки» «їв» дає: Київ
✅ сусідні літерали справді склеїлись в один рядок

2 · Довжина та індекси¶

Нумерація починається з нуля, бо індекс — це відстань від початку. Від'ємний індекс -k означає те саме, що len(рядок) - k.

In [4]:
print("сам рядок:", запис)
print("довжина:  ", len(запис))
print()
print("запис[0]  =", запис[0], "  (перший символ)")
print("запис[4]  =", запис[4], "  (перша крапка з комою)")
print("запис[15] =", запис[15], "  (останній символ)")
print("запис[-1] =", запис[-1], "  (він самий, але з кінця)")
print("запис[-6] =", запис[-6], "  (початок ціни)")
print()
print("правило перекладу: запис[-6] == запис[len(запис) - 6] ->",
      запис[-6] == запис[len(запис) - 6])
сам рядок: Київ;кава;249.50
довжина:   16

запис[0]  = К   (перший символ)
запис[4]  = ;   (перша крапка з комою)
запис[15] = 0   (останній символ)
запис[-1] = 0   (він самий, але з кінця)
запис[-6] = 2   (початок ціни)

правило перекладу: запис[-6] == запис[len(запис) - 6] -> True

За межі вийти не можна¶

IndexError — це добра помилка: вона зупиняє програму одразу, а не дає їй далі працювати зі сміттям.

In [5]:
try:
    print(запис[16])
except IndexError as помилка:
    print("запис[16] -> IndexError:", помилка)

print()
print("а от зріз за межами не падає взагалі:")
print("  запис[10:100] =", repr(запис[10:100]))
print("  запис[50:60]  =", repr(запис[50:60]), " (просто порожньо)")
запис[16] -> IndexError: string index out of range

а от зріз за межами не падає взагалі:
  запис[10:100] = '249.50'
  запис[50:60]  = ''  (просто порожньо)

3 · Зрізи: [start:stop:step]¶

Верхня межа не входить у результат. Саме тому довжина зрізу дорівнює stop - start, а сусідні шматки стикуються без дірок і без нахлесту.

In [6]:
print("запис          =", запис)
print("запис[:4]      =", запис[:4], "   місто")
print("запис[5:9]     =", запис[5:9], "   товар")
print("запис[-6:]     =", запис[-6:], " ціна")
print("запис[::2]     =", запис[::2], " кожен другий символ")
print("запис[::-1]    =", запис[::-1], " увесь рядок навпаки")
print()
print("довжина зрізу [5:9] =", len(запис[5:9]), " а stop - start =", 9 - 5)
запис          = Київ;кава;249.50
запис[:4]      = Київ    місто
запис[5:9]     = кава    товар
запис[-6:]     = 249.50  ціна
запис[::2]     = Кї;аа295  кожен другий символ
запис[::-1]    = 05.942;авак;вїиК  увесь рядок навпаки

довжина зрізу [5:9] = 4  а stop - start = 4

Головна властивість напіввідкритого інтервалу¶

рядок[:k] + рядок[k:] дає вихідний рядок — для будь-якого місця розрізу. Перевіримо це на всіх можливих k одразу.

In [7]:
# цикл ще попереду за програмою (тема 12) — тут він лише щоб перебрати всі місця розрізу
розбіжностей = 0
for місце in range(len(запис) + 1):
    if запис[:місце] + запис[місце:] != запис:
        розбіжностей += 1

print("перевірено місць розрізу:", len(запис) + 1)
print("розбіжностей:", розбіжностей)

assert розбіжностей == 0, "зрізи не стикуються — щось не так з розумінням меж"
print()
print("✅ рядок[:k] + рядок[k:] == рядок для всіх k від 0 до", len(запис))
перевірено місць розрізу: 17
розбіжностей: 0

✅ рядок[:k] + рядок[k:] == рядок для всіх k від 0 до 16

Зріз усього рядка не створює копії¶

Для незмінного об'єкта копія не має сенсу — тому рядок[:] повертає той самий об'єкт. Порівняй із темою 03: is питає про тотожність об'єктів, а не про рівність значень.

In [8]:
копія = запис[:]

print("копія == запис ->", копія == запис, "  (значення однакове)")
print("копія is запис ->", копія is запис, "  (і обʼєкт теж той самий!)")
print()
print("id(запис) =", id(запис))
print("id(копія) =", id(копія))

assert копія is запис, "для рядка зріз [:] мав би повернути той самий обʼєкт"
print()
print("✅ Python не копіює те, що однаково неможливо змінити")
копія == запис -> True   (значення однакове)
копія is запис -> True   (і обʼєкт теж той самий!)

id(запис) = 139974252424960
id(копія) = 139974252424960

✅ Python не копіює те, що однаково неможливо змінити

4 · Незмінність: головна властивість теми¶

Спроба присвоїти символ зупиняє програму. Не «не спрацювала», а тип не має такої операції взагалі.

In [9]:
try:
    запис[0] = "Л"
except TypeError as помилка:
    print('запис[0] = "Л"  ->  TypeError:', помилка)

print()
print("а ось як робиться «зміна першого символу» насправді:")
новий = "Л" + запис[1:]
print("  було:", запис)
print("  стало:", новий)
print()
print("id старого:", id(запис))
print("id нового: ", id(новий), " — це інший обʼєкт")
запис[0] = "Л"  ->  TypeError: 'str' object does not support item assignment

а ось як робиться «зміна першого символу» насправді:
  було: Київ;кава;249.50
  стало: Лиїв;кава;249.50

id старого: 139974252424960
id нового:  139974252528912  — це інший обʼєкт

Найпоширеніша помилка новачка¶

Методи повертають новий рядок і нічого не міняють на місці. Якщо результат нікуди не записати, він зникне тієї ж миті.

In [10]:
напій = "кава"
id_до = id(напій)

напій.upper()                       # результат створено... і викинуто
print("після напій.upper() без присвоєння:", напій)
print("id не змінився:", id(напій) == id_до)
print()

напій = напій.upper()               # ось тепер ярлик перевішено
print("після напій = напій.upper(): ", напій)
print("id змінився:", id(напій) != id_до)

assert напій == "КАВА"
print()
print("✅ метод повертає новий рядок — його треба кудись записати")
після напій.upper() без присвоєння: кава
id не змінився: True

після напій = напій.upper():  КАВА
id змінився: True

✅ метод повертає новий рядок — його треба кудись записати

5 · Методи: регістр і краї¶

Регістр — властивість мови, а не байтів. Німецька ß у верхньому регістрі перетворюється на дві літери, тому довжина рядка може змінитись.

In [11]:
товар = "кава мелена"

print("upper():     ", товар.upper())
print("title():     ", товар.title())
print("capitalize():", товар.capitalize())
print("lower():     ", "КАВА МЕЛЕНА".lower())
print()
print('"ß".upper() =', repr("ß".upper()), " довжина була 1, стала", len("ß".upper()))
print()
# casefold агресивніший за lower і призначений саме для порівняння
print('"Straße".casefold() =', "Straße".casefold())
print('"STRASSE".casefold() =', "STRASSE".casefold())
print("порівняння через casefold:", "Straße".casefold() == "STRASSE".casefold())
print("а через lower:            ", "Straße".lower() == "STRASSE".lower())
upper():      КАВА МЕЛЕНА
title():      Кава Мелена
capitalize(): Кава мелена
lower():      кава мелена

"ß".upper() = 'SS'  довжина була 1, стала 2

"Straße".casefold() = strasse
"STRASSE".casefold() = strasse
порівняння через casefold: True
а через lower:             False

Пастка strip з аргументом¶

.strip("абв") знімає будь-які символи з набору, а не підрядок «абв». Якщо потрібен саме префікс чи суфікс — є точні методи.

In [12]:
брудний = "   Київ ; кава ; 249.50   "

print("як є:      ", repr(брудний))
print("strip():   ", repr(брудний.strip()))
print("lstrip():  ", repr(брудний.lstrip()))
print("rstrip():  ", repr(брудний.rstrip()))
print()
print('очікувано «кава», а насправді:', repr("кава".strip("ак")))
print("  бо злетіли «к» і «а» зліва та «а» справа — це набір символів, не підрядок")
print()
print('removeprefix("Київ;"):', repr(запис.removeprefix("Київ;")))
print('removesuffix(".50"):  ', repr(запис.removesuffix(".50")))
як є:       '   Київ ; кава ; 249.50   '
strip():    'Київ ; кава ; 249.50'
lstrip():   'Київ ; кава ; 249.50   '
rstrip():   '   Київ ; кава ; 249.50'

очікувано «кава», а насправді: 'в'
  бо злетіли «к» і «а» зліва та «а» справа — це набір символів, не підрядок

removeprefix("Київ;"): 'кава;249.50'
removesuffix(".50"):   'Київ;кава;249'

6 · Пошук: find, count, in¶

find повертає індекс першого входження або -1. Це найнебезпечніше число теми: воно і не «хибне» для умови, і водночас є валідним індексом останнього символу.

In [13]:
print('запис.find(";")      =', запис.find(";"))
print('запис.rfind(";")     =', запис.rfind(";"))
print('запис.count(";")     =', запис.count(";"))
print('запис.find("кава")   =', запис.find("кава"))
print('запис.find("гроші")  =', запис.find("гроші"), " <- не знайдено")
print()
print('"кава" in запис      =', "кава" in запис)
print('"гроші" in запис     =', "гроші" in запис)
print('запис.startswith("Київ") =', запис.startswith("Київ"))
print('запис.endswith(".50")    =', запис.endswith(".50"))
запис.find(";")      = 4
запис.rfind(";")     = 9
запис.count(";")     = 2
запис.find("кава")   = 5
запис.find("гроші")  = -1  <- не знайдено

"кава" in запис      = True
"гроші" in запис     = False
запис.startswith("Київ") = True
запис.endswith(".50")    = True

Чому if рядок.find(x): — хибна перевірка¶

Вона помиляється двічі: пропускає знахідку на позиції 0 (бо 0 хибний) і приймає -1 за успіх (бо -1 істинний).

In [14]:
шукане = "Київ"          # стоїть на самому початку, індекс 0

print("наївна перевірка if запис.find(шукане):")
if запис.find(шукане):
    print("  знайшло")
else:
    print("  «не знайшло» — а насправді воно на позиції", запис.find(шукане))

print()
print("правильна перевірка:")
if запис.find(шукане) >= 0:
    print("  знайшло на позиції", запис.find(шукане))

print()
print("найпростіша й найчитабельніша:")
if шукане in запис:
    print("  знайшло")
наївна перевірка if запис.find(шукане):
  «не знайшло» — а насправді воно на позиції 0

правильна перевірка:
  знайшло на позиції 0

найпростіша й найчитабельніша:
  знайшло

7 · split і join: розібрати й зібрати¶

.split(роздільник) ріже рядок на частини, .join(частини) склеює їх назад. Зверни увагу: join — метод роздільника, а не списку.

In [15]:
поля = запис.split(";")
print("запис.split(';') ->", поля)
print("частин:", len(поля))
print()

місто, товар, ціна_рядком = поля[0], поля[1], поля[2]
print("місто =", місто)
print("товар =", товар)
print("ціна  =", ціна_рядком, " (це поки що рядок, не число!)")
print()

ціна = float(ціна_рядком)
print("після float():", ціна, type(ціна))
запис.split(';') ->
 ['Київ', 'кава', '249.50']
частин: 3

місто = Київ
товар = кава
ціна  = 249.50  (це поки що рядок, не число!)

після float(): 249.5 <class 'float'>

Три тонкощі, на яких спотикаються всі¶

In [16]:
брудний = "  Київ ; кава ; 249.50  "

print("1) .split() без аргументу — окрема операція:")
print("   по пробілах:", брудний.split())
print("   по ';':     ", брудний.split(";"))
print()

print("2) роздільник поспіль дає порожні частини:")
print("   'a;;b'.split(';') =", "a;;b".split(";"))
print("   ';a;'.split(';')  =", ";a;".split(";"))
print()

print("3) join приймає ЛИШЕ рядки:")
try:
    ";".join(["кава", 3])
except TypeError as помилка:
    print("   ';'.join(['кава', 3]) -> TypeError:", помилка)
print("   правильно:", ";".join(["кава", str(3)]))
1) .split() без аргументу — окрема операція:
   по пробілах: ['Київ', ';', 'кава', ';', '249.50']
   по ';':      ['  Київ ', ' кава ', ' 249.50  ']

2) роздільник поспіль дає порожні частини:
   'a;;b'.split(';') = ['a', '', 'b']
   ';a;'.split(';')  = ['', 'a', '']

3) join приймає ЛИШЕ рядки:
   ';'.join(['кава', 3]) -> TypeError: sequence item 1: expected str instance, int found
   правильно: кава;3

Прибираємо пробіли й збираємо назад¶

Класичний конвеєр: розрізали → почистили кожну частину → склеїли новим клеєм.

In [17]:
частини = брудний.split(";")
print("як розрізалось:", частини)

# чистимо кожну частину окремо; цикл — тема 12, тут він лише щоб не писати три рядки
чисті = []
for частина in частини:
    чисті.append(частина.strip())

print("після strip():  ", чисті)
print()
print("склеюємо назад через ' | ':")
print("  ", " | ".join(чисті))
print("склеюємо назад через ';':")
print("  ", ";".join(чисті))

assert ";".join(чисті) == запис, "після чистки й склеювання мав вийти вихідний запис"
print()
print("✅ розібрали брудний рядок і зібрали з нього рівно наш запис")
як розрізалось: ['  Київ ', ' кава ', ' 249.50  ']
після strip():   ['Київ', 'кава', '249.50']

склеюємо назад через ' | ':
   Київ | кава | 249.50
склеюємо назад через ';':
   Київ;кава;249.50

✅ розібрали брудний рядок і зібрали з нього рівно наш запис

8 · Перевірка: наш split проти вбудованого¶

Найцінніше, що дає практика, — побачити, що всередині бібліотеки немає магії. Напишемо власне розбиття, спираючись лише на find і зрізи.

In [18]:
def мій_split(рядок, роздільник):
    """Ріже рядок на частини так само, як str.split(роздільник).

    Ідея: щоразу шукаємо найближчий роздільник, відрізаємо все до нього,
    а працювати продовжуємо з тим, що лишилось після нього.
    """
    частини = []
    залишок = рядок
    # цикл потрібен, бо роздільників може бути скільки завгодно (тема 12)
    while роздільник in залишок:
        межа = залишок.find(роздільник)
        частини.append(залишок[:межа])                    # усе до роздільника
        залишок = залишок[межа + len(роздільник):]        # усе після нього
    частини.append(залишок)                               # хвіст після останнього
    return частини


проби = [запис, "a;;b", ";a;", "без роздільника", "", ";;;"]

print(f"{'вхід':<20} {'наш':<28} {'вбудований'}")
print("-" * 78)
for проба in проби:
    print(f"{проба!r:<20} {мій_split(проба, ';')!s:<28} {проба.split(';')}")
    assert мій_split(проба, ";") == проба.split(";"), f"розійшлись на {проба!r}"

print()
print("✅ наш split дає те саме, що й вбудований, на всіх шести пробах")
вхід                 наш                          вбудований
------------------------------------------------------------------------------
'Київ;кава;249.50'   ['Київ', 'кава', '249.50']   ['Київ', 'кава', '249.50']
'a;;b'               ['a', '', 'b']               ['a', '', 'b']
';a;'                ['', 'a', '']                ['', 'a', '']
'без роздільника'    ['без роздільника']          ['без роздільника']
''                   ['']                         ['']
';;;'                ['', '', '', '']             ['', '', '', '']

✅ наш split дає те саме, що й вбудований, на всіх шести пробах

9 · f-рядки: підстановка виразів¶

Літера f перед лапками вмикає підстановку. Усередині фігурних дужок може стояти будь-який вираз, а не тільки ім'я.

In [19]:
кількість = 3
сума = ціна * кількість

print(f"{місто}: {товар} по {ціна} грн")
print(f"три пачки коштують {ціна * кількість} грн")
print()
print("!r друкує технічне представлення:")
print(f"  звичайно: {товар}")
print(f"  з !r:     {товар!r}")
print()
print("а знак = друкує одразу і вираз, і значення — найшвидший спосіб налагодження:")
print(f"  {ціна=}")
print(f"  {сума=}")
print(f"  {len(запис)=}")
Київ: кава по 249.5 грн
три пачки коштують 748.5 грн

!r друкує технічне представлення:
  звичайно: кава
  з !r:     'кава'

а знак = друкує одразу і вираз, і значення — найшвидший спосіб налагодження:
  ціна=249.5
  сума=748.5
  len(запис)=16

Міні-мова формату: ширина, вирівнювання, точність¶

Після двокрапки описують, як саме друкувати. Порядок частин фіксований: [заповнювач][< > ^][знак][ширина][,][.точність][тип].

In [20]:
print(f'{"праворуч":>20}|')
print(f'{"ліворуч":<20}|')
print(f'{"по центру":^20}|')
print(f'{"крапками":.^20}|')
print()
print("249.5 з двома знаками:   ", f"{ціна:.2f}")
print("із роздільником тисяч:   ", f"{1234567.891:,.2f}")
print("зі знаком:               ", f"{ціна:+.2f}")
print("нулями до ширини 10:     ", f"{ціна:010.2f}")
print("у відсотках:             ", f"{0.075:.1%}")
print()
print("а ось помилка — числовий формат до тексту:")
try:
    print(f"{товар:.2f}")
except ValueError as помилка:
    print("   ValueError:", помилка)
            праворуч|
ліворуч             |
     по центру      |
......крапками......|

249.5 з двома знаками:    249.50
із роздільником тисяч:    1,234,567.89
зі знаком:                +249.50
нулями до ширини 10:      0000249.50
у відсотках:              7.5%

а ось помилка — числовий формат до тексту:
   ValueError: Unknown format code 'f' for object of type 'str'

Складаємо звіт¶

Три позиції, ціни різної довжини — і все одно рівна колонка. Саме заради цього й потрібні ширина з точністю.

In [21]:
позиції = [("кава мелена", 3, 249.5), ("цукор", 12, 34.9), ("чай зелений", 1, 1250.0)]

print(f"{'товар':<16}{'к-сть':>7}{'ціна':>12}{'разом':>14}")
print("-" * 49)

# цикл — лише щоб не писати три однакові рядки вручну
загальна = 0.0
for назва, шт, ціна_шт in позиції:
    разом = шт * ціна_шт
    загальна += разом
    print(f"{назва:<16}{шт:>7}{ціна_шт:>12,.2f}{разом:>14,.2f}")

print("-" * 49)
print(f"{'РАЗОМ':<16}{'':>7}{'':>12}{загальна:>14,.2f}")
товар             к-сть        ціна         разом
-------------------------------------------------
кава мелена           3      249.50        748.50
цукор                12       34.90        418.80
чай зелений           1    1,250.00      1,250.00
-------------------------------------------------
РАЗОМ                                    2,417.30

Перевірка: наше вирівнювання проти format¶

Вирівнювання праворуч — це просто «дописати спереду стільки пробілів, скільки бракує». Переконаємось, що вбудований format робить рівно те саме.

In [22]:
def моє_вирівнювання_праворуч(текст, ширина):
    """Доповнює текст пробілами зліва до заданої ширини.

    Ширина — це мінімум: якщо текст довший, нічого не обрізається.
    """
    бракує = ширина - len(текст)
    if бракує <= 0:
        return текст
    return " " * бракує + текст


проби = [("кава", 10), ("249.50", 10), ("довгий рядок", 5), ("", 4)]

print(f"{'вхід':<24}{'наше':<18}{'format'}")
print("-" * 60)
for текст, ширина in проби:
    наше = моє_вирівнювання_праворуч(текст, ширина)
    бібліотечне = format(текст, ">" + str(ширина))
    print(f"{(текст, ширина)!s:<24}{наше!r:<18}{бібліотечне!r}")
    assert наше == бібліотечне, f"розійшлись на {текст!r}"

print()
print("✅ наше вирівнювання збіглося з format на всіх пробах")
вхід                    наше              format
------------------------------------------------------------
('кава', 10)            '      кава'      '      кава'
('249.50', 10)          '    249.50'      '    249.50'
('довгий рядок', 5)     'довгий рядок'    'довгий рядок'
('', 4)                 '    '            '    '

✅ наше вирівнювання збіглося з format на всіх пробах

10 · Символи, коди й байти¶

len рахує символи, а не байти. Кириличний символ у UTF-8 займає два байти, латинський — один, емодзі — чотири.

In [23]:
print("запис          =", запис)
print("len(запис)     =", len(запис), " символів")
print("байтів у UTF-8 =", len(запис.encode()), " байтів")
print()
print("ord('a') =", ord("a"), "  chr(97) =", chr(97))
print("ord('ї') =", ord("ї"), " chr(1111) =", chr(1111))
print("ord('🙂') =", ord("🙂"), " (для Python це ОДИН символ)")
print()
print("len('їжак')           =", len("їжак"))
print("len('їжак'.encode())  =", len("їжак".encode()))

assert len(запис) == 16, "у записі має бути 16 символів"
assert len(запис.encode()) == 24, "у записі має бути 24 байти"
print()
print("✅ 16 символів і 24 байти — розбіжність саме через кирилицю")
запис          = Київ;кава;249.50
len(запис)     = 16  символів
байтів у UTF-8 = 24  байтів

ord('a') = 97   chr(97) = a
ord('ї') = 1111  chr(1111) = ї
ord('🙂') = 128578  (для Python це ОДИН символ)

len('їжак')           = 4
len('їжак'.encode())  = 8

✅ 16 символів і 24 байти — розбіжність саме через кирилицю

str і bytes — різні типи¶

encode перетворює рядок на байти, decode — назад. Складати їх один з одним не можна: це навмисна стіна.

In [24]:
байти = "Київ".encode()

print("'Київ'.encode() =", байти)
print("тип:", type(байти), " довжина:", len(байти))
print("назад:", байти.decode())
print()

try:
    "Київ" + байти
except TypeError as помилка:
    print("'Київ' + b'...' -> TypeError:", помилка)

print()
print("а ось звідки беруться крякозябри — байти UTF-8 прочитали як cp1251:")
print("  ", байти.decode("cp1251"))
print("   помилки не було! неправильне кодування часто «спрацьовує успішно»")
'Київ'.encode() = b'\xd0\x9a\xd0\xb8\xd1\x97\xd0\xb2'
тип: <class 'bytes'>  довжина: 8
назад: Київ

'Київ' + b'...' -> TypeError: can only concatenate str (not "bytes") to str

а ось звідки беруться крякозябри — байти UTF-8 прочитали як cp1251:
   РљРёС—РІ
   помилки не було! неправильне кодування часто «спрацьовує успішно»

Перевірка: наш підрахунок байтів проти encode¶

UTF-8 влаштований просто: кодова позиція до 128 — один байт, до 2048 — два, до 65536 — три, далі — чотири. Реалізуємо це правило й порівняємо з бібліотекою.

In [25]:
def байтів_на_символ(символ):
    """Скільки байтів займе цей символ у UTF-8 — за таблицею меж стандарту."""
    код = ord(символ)
    if код < 0x80:      # 128 — уся латиниця й цифри
        return 1
    if код < 0x800:     # 2048 — кирилиця й більшість європейських абеток
        return 2
    if код < 0x10000:   # 65536 — ієрогліфи, валютні знаки
        return 3
    return 4            # емодзі та решта


проби = ["a", "ї", "€", "🙂", "їжак", "Львів", запис]

print(f"{'рядок':<20}{'символів':>10}{'наші байти':>13}{'encode':>9}")
print("-" * 52)
for проба in проби:
    # цикл — лише щоб пройтись символами рядка (тема 12)
    наші_байти = 0
    for символ in проба:
        наші_байти += байтів_на_символ(символ)
    бібліотечні = len(проба.encode())
    print(f"{проба!r:<20}{len(проба):>10}{наші_байти:>13}{бібліотечні:>9}")
    assert наші_байти == бібліотечні, f"розійшлись на {проба!r}"

print()
print("рядок з емодзі трохи зсунув колонку: f-рядок рахує ширину в символах,")
print("а термінал малює емодзі завширшки у два — це вже не про Python")
print()
print("✅ наш підрахунок збігся з len(рядок.encode()) на всіх пробах")
рядок                 символів   наші байти   encode
----------------------------------------------------
'a'                          1            1        1
'ї'                          1            2        2
'€'                          1            3        3
'🙂'                          1            4        4
'їжак'                       4            8        8
'Львів'                      5           10       10
'Київ;кава;249.50'          16           24       24

рядок з емодзі трохи зсунув колонку: f-рядок рахує ширину в символах,
а термінал малює емодзі завширшки у два — це вже не про Python

✅ наш підрахунок збігся з len(рядок.encode()) на всіх пробах

11 · += проти join: рахуємо роботу¶

Кожне += над рядком створює новий об'єкт і копіює туди все, що вже було. Тому на n шматках довжиною L копіюється L·(1+2+…+n) символів — квадратично. join копіює кожен символ рівно один раз.

In [26]:
шматок = "кава; "
L = len(шматок)
n = 60

# рахуємо копіювання явно, крок за кроком (цикл — тема 12)
скопійовано_плюсом = 0
результат = ""
for крок in range(n):
    результат += шматок
    скопійовано_плюсом += len(результат)   # стільки символів має новий обʼєкт

скопійовано_джойном = L * n
формула = L * n * (n + 1) // 2

print("довжина шматка L =", L, "  кількість шматків n =", n)
print()
print("скопійовано символів через +=  :", скопійовано_плюсом)
print("те саме за формулою L·n(n+1)/2 :", формула)
print("скопійовано символів через join:", скопійовано_джойном)
print("різниця:", round(скопійовано_плюсом / скопійовано_джойном, 1), "раза")
print()
print("створено проміжних обʼєктів:  += ->", n, "   join ->", 1)

assert скопійовано_плюсом == формула, "квадратична формула не збіглася з підрахунком"
print()
print("✅ підрахунок крок за кроком збігся з формулою L·n(n+1)/2")
довжина шматка L = 6   кількість шматків n = 60

скопійовано символів через +=  : 10980
те саме за формулою L·n(n+1)/2 : 10980
скопійовано символів через join: 360
різниця: 30.5 раза

створено проміжних обʼєктів:  += -> 60    join -> 1

✅ підрахунок крок за кроком збігся з формулою L·n(n+1)/2
In [27]:
частини = [шматок] * n           # той самий шматок n разів
через_join = "".join(частини)

print("результати збігаються:", через_join == результат)
print("довжина обох:", len(через_join))

assert через_join == результат, "join мав дати той самий рядок, що й цикл із +="
print()
print("✅ той самий результат — але роботи в", 
      round(скопійовано_плюсом / скопійовано_джойном, 1), "раза менше")
результати збігаються: True
довжина обох: 360

✅ той самий результат — але роботи в 30.5 раза менше

12 · Інтернування: коли однакові рядки — один об'єкт¶

Python тримає частину рядків у спільній таблиці, щоб однаковий текст існував в одному екземплярі. Автоматично туди потрапляють ті, що схожі на ідентифікатори: самі лише латинські літери, цифри й підкреслення. Для решти — як пощастить. А рядок, зібраний під час роботи програми, — завжди окремий об'єкт.

Зверни увагу на другий рядок виводу. У файлі .py два однакові кириличні літерали зазвичай дають True, а тут, у зошиті, — False. Це і є найкращий аргумент проти is: відповідь залежить від того, як компілювався код.

In [28]:
import sys

латиною_1 = "kava"
латиною_2 = "kava"              # схоже на ідентифікатор — інтернується автоматично

кирилицею_1 = "кава"
кирилицею_2 = "кава"            # не схоже — гарантій немає

зібраний = "".join(["ка", "ва"])  # той самий текст, але склеєний у рантаймі

print('"kava" is "kava" ->', латиною_1 is латиною_2, "  (інтерновано)")
print('"кава" is "кава" ->', кирилицею_1 is кирилицею_2, " (як пощастить!)")
print("зібраний is літерал ->", зібраний is кирилицею_1, " (завжди інший обʼєкт)")
print()
print("а от значення в усіх трьох однакове:")
print("  зібраний == кирилицею_1 ->", зібраний == кирилицею_1)
print()
print("id(кирилицею_1) =", id(кирилицею_1))
print("id(зібраний)    =", id(зібраний))
print()
спільний = sys.intern(зібраний)   # явно кладемо в спільну таблицю
print("sys.intern(зібраний) is зібраний ->", спільний is зібраний)

assert зібраний == кирилицею_1 and зібраний is not кирилицею_1
print()
print("✅ рівність значень і тотожність обʼєктів — різні питання;")
print("   для рядків завжди питай ==, а не is")
"kava" is "kava" -> True   (інтерновано)
"кава" is "кава" -> False  (як пощастить!)
зібраний is літерал -> False  (завжди інший обʼєкт)

а от значення в усіх трьох однакове:
  зібраний == кирилицею_1 -> True

id(кирилицею_1) = 139974252949424
id(зібраний)    = 139974252948464

sys.intern(зібраний) is зібраний -> True

✅ рівність значень і тотожність обʼєктів — різні питання;
   для рядків завжди питай ==, а не is

13 · Сортування рядків — це порядок кодів, а не абетка¶

Рядки порівнюються посимвольно за кодовими позиціями Unicode. Українська ґ має номер 1169 — після всіх інших кириличних літер, тому в кінці списку.

In [29]:
слова = ["ґанок", "яблуко", "їжак", "абрикос"]

print("як відсортував Python:", sorted(слова))
print()
print("а ось чому — подивись на номери перших літер:")
print("  'а' ->", ord("а"))
print("  'я' ->", ord("я"))
print("  'ї' ->", ord("ї"))
print("  'ґ' ->", ord("ґ"))
print()
print("'Я' < 'а' ->", "Я" < "а", "  бо великі літери мають менші номери:",
      ord("Я"), "<", ord("а"))

assert sorted(слова) == ["абрикос", "яблуко", "їжак", "ґанок"]
print()
print("✅ типове сортування рядків — порядок кодів, а не українська абетка")
як відсортував Python: ['абрикос', 'яблуко', 'їжак', 'ґанок']

а ось чому — подивись на номери перших літер:
  'а' -> 1072
  'я' -> 1103
  'ї' -> 1111
  'ґ' -> 1169

'Я' < 'а' -> True   бо великі літери мають менші номери: 1071 < 1072

✅ типове сортування рядків — порядок кодів, а не українська абетка

Підсумок¶

Що ми перевірили власними руками:

  • рядок[:k] + рядок[k:] == рядок для всіх місць розрізу;
  • рядок[:] для рядка повертає той самий об'єкт, а не копію;
  • присвоєння в символ дає TypeError, а «зміна» — це новий об'єкт з новим id;
  • наш split на find і зрізах дає те саме, що вбудований;
  • наше вирівнювання пробілами дає те саме, що format(текст, ">N");
  • наш підрахунок байтів за межами UTF-8 дає те саме, що len(рядок.encode());
  • покрокове копіювання при += точно збігається з формулою L·n(n+1)/2;
  • однакові за значенням рядки можуть бути різними об'єктами.

Головне: всередині рядкових методів немає магії. Там ті самі кілька рядків, які ти щойно написав сам — просто виконані швидким кодом на C.


Завдання¶

🟢 Рівень 1 — База¶

Візьми рядок " Львів ; чай зелений ; 1250.00 ; 2 " і розбери його на чотири поля: місто, товар, ціну (числом) і кількість (цілим). Надрукуй їх по одному в рядок.

Зроблено, якщо: виконується

assert поля == ["Львів", "чай зелений", "1250.00", "2"]
assert isinstance(ціна, float) and isinstance(кількість, int)

🟡 Рівень 2 — Плюс¶

Склади з цих даних однорядковий чек шириною рівно 46 символів: назва притиснута ліворуч, сума — праворуч із двома знаками після коми й роздільником тисяч.

Зроблено, якщо: len(чек) == 46 і в рядку видно 2 500.00 у вигляді 2,500.00, а між назвою та сумою — суцільні крапки-заповнювачі.

🔴 Рівень 3 — Виклик¶

Напиши функцію розвернути_слова(текст), яка розвертає порядок слів, але не самі слова: "кава мелена мелена" → "мелена мелена кава". Використай лише split, зрізи й join — без reversed і без циклів.

Зроблено, якщо: виконуються всі три перевірки

assert розвернути_слова("кава мелена мелена") == "мелена мелена кава"
assert розвернути_слова("одне") == "одне"
assert розвернути_слова("  зайві   пробіли  ") == "пробіли зайві"

Повні умови з підказками — у homework.md.