Об’єкти в Python

Усі приклади коду тестувались в Python 3.9.

В інтерактивному інтерпретаторі Python запишемо інструкцію:

>>> a = 39

У звичайному випадку, в мові програмування даний запис можна прочитати так: змінній a присвоїти цілочисельне значення 39. Символом = тут позначається оператор присвоєння.

Змінні в Python - імена-посилання на об’єкти.

Наприклад, у записі a = 39, a - посилання на об’єкт (екземпляр класу int) - ціле число 39.

Виконаємо ще одне присвоєння:

>>> b = a

Запис b = a змушує ім’я b посилатися на той самий об’єкт, що й ім’я a. Перевіримо це твердження:

>>> a is b
True

Операція a is b перевіряє, чи вказують обидва імені на один і той же об’єкт.

Оператор is перевіряє об’єкти на ідентичність:

PEP8 рекомендує використовувати оператор is для порівняння з singleton-об’єктами (існують в єдиному екземплярі), наприклад, для None:

if foo is not None:
    pass

Перевірку на ідентичність можна виконати також за допомогою функції id() (для реалізації CPython - найбільш поширена, еталонна реалізація інтерпретатора мови програмування Python):

>>> id(a) == id(b)
True
>>> id(a)
140713044069216
>>> id(b)
140713044069216

Функція id() повертає унікальний ідентифікатор для зазначеного об’єкта - значення його адреси в пам’яті.

Ідентифікатор - це ціле число, яке гарантовано є унікальним та постійним для цього об’єкта протягом його життя.

Для порівняння ідентифікаторів об’єктів ми використали оператор порівняння ==.

Щоб виконати зворотну дію - отримати значення об’єкта за його адресою в пам’яті, можна використати функцію PyObj_FromPtr(), яка надається вбудованим модулем _ctypes:

>>> year = 2022
>>> id(year)
2392913595504
>>> import _ctypes
>>> print(_ctypes.PyObj_FromPtr(2392913595504))
2022

Проілюструємо механізми управління пам’яттю ще на одному прикладі.

>>> a = 3
>>> b = 3
>>> id(3) == id(a) == id(b)
True
>>> id(3)
140713044068064
>>> id(a)
140713044068064
>>> id(b)
140713044068064
>>> c = 257
>>> d = 257
>>> id(257) == id(c) == id(d)
False
>>> id(257)
1980287707152
>>> id(c)
1980287707408
>>> id(d)
1980287707120
>>>

Ідентичність об’єкта асоціюється з адресою об’єкта в пам’яті і буде різною для кожного запуску програми, окрім деяких об’єктів, які мають постійний унікальний ідентифікатор, таких, як цілі числа від -5 до 256 включно.

Коли ви створюєте об’єкт типу int у цьому діапазоні, ви, фактично, просто отримуєте посилання на вже існуючий об’єкт, а не на новий об’єкт. А от, наприклад, для числа 257 буде створений новий об’єкт (Цілі об’єкти).

Інтерпретатор Python оптимізований так, що невеликі цілі числа представлені одним об’єктом - це зроблено з метою поліпшення продуктивності. Для великих чисел це вже не виконується.

Інші реалізації Python, такі як Jython, IronPython, можуть мати іншу реалізацію функції id(). Тому, наведені приклади є особливостями реалізації інтерпретатора, а не мовною особливістю Python.

У стандартній реалізації CPython (написаний на C) є багато заздалегідь визначених об’єктів, включаючи кілька малих цілих чисел та кілька одиничних символів ASCII.

Реальний код повинен бути незалежним від деталей реалізації. Тому, для порівняння чисел, не варто використовувати перевірку на ідентичність (різні об’єкти можуть мати одне значення). Використовуйте a == b, щоб дізнатися чи рівні числа. Наприклад:

>>> x = 39
>>> y = 39
>>> x == y # має бути два різних об'єкти з однаковими значеннями: порівняння
True
>>> x is y # той самий об'єкт: кешування
True
>>>

У цьому прикладі змінні x і y повинні бути рівні (==, одне і те ж значення), але не еквівалентні (is, один і той же об’єкт). Однак, через те, що малі цілі числа і рядки кешуються та використовуються повторно, оператор is повідомляє, що імена-змінні посилаються на один і той же об’єкт.

Отже, коли ви запитаєте Python, чи дійсно -5 is -5, це, безумовно, буде True, тому що обидва екземпляри є одним і тим же екземпляром, тоді, як поза діапазоном [-5, 256], CPython може створювати і, ймовірно, створює нові екземпляри цих цілих чисел.

Проілюструємо вищесказане на прикладі.

>>> a = 257
>>> b = 257
>>> a is b
False
>>> id(a) == id(b)
False
>>> id(a)
1624253033232
>>> id(b)
1624253032464
>>> 257 is 257
<stdin>:1: SyntaxWarning: "is" with a literal. Did you mean "=="?
True
>>> id(257) == id(257)
True
>>>

Чому 257 is 257 дає значення True? Це пов’язано з тим, що Python виконує цю інтерактивну інструкцію як єдиний блок (в реалізації CPython). Під час компіляції цього твердження CPython побачить, що у вас є два однакові літерали і буде використовувати одне і те ж представлення 257.

У мові програмування Python літерал – це вираз (або константа), що створює (генерує) об’єкт. Якщо в тексті програми зустрічається літерал, то для цього літералу створюється окремий об’єкт деякого типу. Тобто, генерується відповідний код, що створює об’єкт, який містить значення цього літералу. Наприклад, запис a = 5, можна прочитати як за допомогою числового літералу 5 створений цілочисельний об’єкт зі значенням 5, на який посилається ім’я a.

У мовах програмування із суворою типізацією необхідно вказувати як тип числа, так і те, що створюваний об’єкт сам є числом. Але в Python такої необхідності немає, інтерпретатор Python сам, на основі аналізу літералів чисел, здатний зрозуміти, що перед ним: число або ні, ціле або з плаваючою крапкою, двійкове або шістнадцяткове тощо.

Приклади літералів:

3 # числовий літерал
2.48 # числовий літерал
"some text" # рядковий літерал
[3, 5, 7, 9] # літерал списку
{'a': 1, 'b': 2, 'c': 3} # літерал словника

Ще трішки про виконання коду в інтерпретаторі CPython:

>>> id(333)
2609261210384
>>> id(334)
2609261210384
>>> id(335)
2609261210384
>>> id(333), id(334), id(335) 
(2609261210384, 2609261209872, 2609231362288)

Такі результати можна пояснити, звернувшись до документації Python для функції id(): Два об’єкти з періодами життя, що не перекриваються, можуть мати однакове значення id().

Іншими словами, оскільки ми не зберігаємо жодних посилань на ціле число 333, його об’єкт негайно видаляється з пам’яті після друку в інтерактивному інтерпретаторі.

Потім ми створюємо об’єкт 334, і йому видається така сама адреса в пам’яті. Так як немає посилань на ціле число 334, його об’єкт також видаляється з пам’яті. При створенні об’єкта 335 йому знову видається та сама адреса в пам’яті і т.д.

При виконанні інструкції єдиним блоком, ми спотерігаємо різні значення id для поданих цілих об’єктів.

У випадку рядкових значень, у Python присутній механізм зберігання в пам’яті лише однієї копії значення рядка (інтернування рядків). Це означає, що коли створюються два рядки з однаковим значенням – замість виділення пам’яті для обох рядків, лише один рядок фактично фіксується у пам’яті. Інший просто вказує на те саме місце в пам’яті. Завдяки цьому відбувається економія місця в пам’яті і часу при порівнянні рядків, значення яких однакові.

Перша перевага очевидна, оскільки для зберігання лише однієї копії потрібно менше місця, ніж для зберігання кількох копій. Друга перевага полягає в тому, що якщо два рядки посилаються на один і той же об’єкт, вони безумовно рівні один одному, і немає потреби порівнювати їх посимвольно.

Проілюструємо це на прикладі:

>>> s = 'Python'
>>> g = 'Python'
>>> s is g
True
>>> f = 'Python excellent'
>>> j = 'Python excellent'
>>> f is j
False

Використавши оператор is, що порівнює, чи посилаються дві змінні на один і той самий об’єкт чи ні, видно, що s і g посилаються на той самий об’єкт, але f і j - ні. Такий результат зумовлений правилами механізму інтернування рядків у Python, а інтернування у даному випадку виконується неявно.

Зокрема, у прикладі вище рядки f і j містять пропуски, тому вони не були інтерновані. Існують правила, що визначають, чи буде рядок інтернований чи ні. Ці правила відрізняються в залежності від версії та реалізації інтерпретатора Python.

Наприклад, для версії Python 3.9 інтернування рядків виконується, коли довжина рядка становить менше або дорівнює 4096 символів. Перевіримо це:

>>> s1 = 'a' * 4096
>>> s2 = 'a' * 4096
>>> s1 is s2
True
>>> s3 = 'a' * 4097
>>> s4 = 'a' * 4097
>>> s3 is s4
False

Python має у своєму арсеналі вбудовану функцію intern() (у модулі sys), яку можна використовувати для явного інтернування рядків. Поглянемо, як це працює у наступному прикладі:

>>> import sys
>>> s5 = sys.intern('a' * 4097)
>>> s6 = sys.intern('a' * 4097)
>>> s5 is s6
True
>>> id(s5)
2499136942336
>>> id(s6)
2499136942336

Як показано вище, за допомогою функції intern() ми змусили Python інтернувати рядки незалежно від неявних правил.

Використання інтернування рядків гарантує, що буде створено лише один об’єкт, навіть якщо визначається кілька рядків з однаковим вмістом. Однак, слід пам’ятати про баланс між перевагами (економія пам’яті, швидкі порівняння) та недоліками (відносно мало рядків і багато порівнянь між ними, часові витрати на виклик функції intern()) інтернування рядків і використовувати його лише тоді, коли це є корисним.

Обсяг пам’яті не безмежний, і якщо деякі об’єкти нам вже не потрібні, їх необхідно видалити, щоб звільнити пам’ять. Наприклад, у випадку мови програмування C таке видалення доведеться виконувати вручну.

У Python є власний механізм автоматичного збору сміття.

Збирання сміття (англ. garbage collection) - одна з форм автоматичного керування оперативною пам’яттю комп’ютера під час виконання програм. Підпрограма - «прибиральник сміття» - вивільняє пам’ять від об’єктів, які не будуть використовуватись програмою у подальшому. Збирання сміття було винайдено Джоном Мак-Карті приблизно 1959 року для розробленої ним мови програмування LISP.

Простіше кажучи, Python підраховує кількість посилань на об’єкт, і якщо об’єкт не має посилання, його видаляють.

Проілюструємо це на простому прикладі. Визначаємо простий цілий об’єкт 2022 і дві змінні, які посилаються на нього:

>>> year = 2022
>>> y = year
>>> year is y
True
>>> id(year)
1871477591152
>>> id(y)
1871477591152

Як бачимо, дві змінні year і y мають однакову адресу у пам’яті. А тепер виведемо об’єкт за його адресою в пам’яті:

>>> import _ctypes
>>> print(_ctypes.PyObj_FromPtr(1871477591152))
2022

Далі видалимо одне з двох посилань:

>>> del y
>>> print(_ctypes.PyObj_FromPtr(1871477591152))
2022

Отже, в пам’яті ще є місце для об’єкта 2022, оскільки змінна year ще містить посилання на нього. Тепер видалимо year:

>>> del year
>>> print(_ctypes.PyObj_FromPtr(1871477591152))
467077693465

Ми видалили останнє посилання на об’єкт 2022, після цього Python звільнив відповідну адресу пам’яті. Звертатися до цієї адреси не є безпечно, оскільки ми не знаємо наперед, що буде виведено у підсумку.


Для усіх, хто цікавиться програмуванням мовою Python.
Підготував Oleksandr Miziuk 🤠


Джерела знань:
PEP8 - керівництво з написання коду на Python
CPython - реалізація інтерпретатора Python
Цілі об’єкти в Python (офіціна документація)
Мова програмування C (Вікіпедія)
Таблиця кодування ASCII (Вікіпедія)
Джон Мак-Карті (Вікіпедія)
Мова програмування LISP (Вікіпедія)
3 Facts of the Integer Caching in Python
String Interning in Python: A Hidden Gem That Makes Your Code Faster
Guide to String Interning in Python