13  Популярні питання

Data Miorsh Ihor Miroshnychenko Youtube Monobank

13.1 Основи

13.1.1 Що таке PEP-8?

PEP або Python Enhancement Proposal – це пропозиція щодо розвитку мови Python. Ці документи є механізмом для пропонування нових можливостей або для документування вже готових рішень, які увійшли до мови Python.

PEP-8 — це style guide того, як повинен бути оформлений код, написаний мовою Python, і яку повинні слідувати всі розробники, які пишуть цією мовою.

13.1.2 Які типи даних є в Python? Які типи даних є змінними, а які є незмінними?

Типи даних у Python можна розділити на змінні та незмінні.

До незмінних можна віднести: рядки, байти, цілі числа, числа з плаваючою точкою, комплексні числа, булеві значення, none, картежі та frozenset.

До змінюваних типів даних можна віднести списки, сети, словники, байт-масиви та memoryview.

Змінні Незмінні
str list
bytes set
int dict
float bytearray
complex memoryview
bool
None
tuple
frozenset

13.1.3 Що таке віртуальне оточення?

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

13.1.4 Чи можна змінити елемент у кортежі, якщо кортеж незмінний тип даних?

Хоча по собі картеж — це незмінний тип даних, але може містити змінюване значення. Припустимо, у нас є картеж a, який містить список і одиницю.

a = ([], 1)

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

a[3] = 2
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[2], line 1
----> 1 a[3] = 2

TypeError: 'tuple' object does not support item assignment

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

a[0].append(2)
print(a)
([2], 1)

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

13.1.5 Що таке List, Set та Dict comprehensions?

List, Set та Dict comprehensions – це скорочення для наступного запису:

a = []

for i in range(5):
    a.append(i)

print(a)
[0, 1, 2, 3, 4]

Щоб отримати той самий результат, ми можемо скористатися List Comprehensions:

l = [i for i in range(5)]
print(l)
[0, 1, 2, 3, 4]

Також у List, Set та Dict Comprehensions можна використовувати умови. Для цього потрібно після колекції або після генератора вказати ключове слово if і вказати умови, за якими ми вибиратимемо елементи, які вставлятимемо в цей список:

# виведемо парні числа

l = [i for i in range(5) if i % 2 == 0]
print(l)
[0, 2, 4]

Різниця між List і Set Comprehension у тому, що в List Comprehension використовуються квадратні дужки, Set Comprehension використовуються круглі дужки:

s = {i for i in range(5) if i % 2 == 0}
print(s)
{0, 2, 4}

Щоб отримати Dict Comprehension, у нас повинен бути тут не один елемент, а має бути два елементи. Тобто один для ключа, другий для значення. Ми також використовуємо фігурні дужки:

d = {i: i ** 2 for i in range(5) if i % 2 == 0}
print(d)
{0: 0, 2: 4, 4: 16}

В якості прикладів використання List Set та Dict Comprehension можна навести розпакування якихось колекцій, або якихось генераторів, або просто заміна якогось дуже простого циклу, коли це не заважає читабельності коду.

13.1.6 Яка різниця між операторами == та is?

Оператор == порівнює два операнди за їх значеннями:

a = [1, 2, 3]
b = [1, 2, 3]

print(a == b)
True

Оператор is перевіряє, що дві змінні, тобто a і b посилаються на один об’єкт. В даному випадку у нас a посилається на один список, b посилається на інший список, відповідно ми повинні отримати false:

a = [1, 2, 3]
b = [1, 2, 3]

print(a is b)
False

Що буде, якщо b надамо не новий список, а передамо a? Ми отримаємо true, тому що і a, і b посилаються на той самий об’єкт:

a = [1, 2, 3]
b = a

print(a is b)
True

13.1.7 Що таке глибока (deep) та поверхнева (shallow) копія?

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

Розглянемо приклад:

a = [1, [2]]
b = a
b.append(3) # додаємо 3 до b
b[1].append(4) # додаємо 4 до списку, який знаходиться всередині b

print(a)
[1, [2, 4], 3]

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

Найпростіший спосіб це імпортувати модуль copy і скористатися методом copy(). В такому випадку ми отримаємо поверхневу копію. Поверхнева копія створює копію самого об’єкта, але посилання на всі внутрішні об’єкти будуть збережені, вони не будуть скопійовані.

import copy

a = [1, [2]]
b = copy.copy(a)
b.append(3)
b[1].append(4)

print(a)
[1, [2, 4]]

Для створення глибокої копії ми можемо скористатися методом deepcopy():

import copy

a = [1, [2]]
b = copy.deepcopy(a)
b.append(3)
b[1].append(4)

print(a)
[1, [2]]

Якщо ж ми розглядаємо список, то існує багато способів, як ще можна створити поверхневу копію:

a = [1, [2]]

b = list(a) # поверхнева копія через list()
b = a[:] # поверхнева копія через зріз
b = [i for i in a] # поверхнева копія через List Comprehension
b = a.copy() # поверхнева копія через вбудовану функцію copy()

13.1.8 Як працюють оператори and, or та not?

Оператор not приводить свій операнд до типу bool і повертає його інверсію:

# not

print(not [])
True
# not

print(not {1, 2, 3})
False

Оператори and та or не приводять об’єкти до булевих значень, вони повертають самі об’єкти.

Якщо перший операнд повертає False, то and поверне його:

print([] and 123)
[]

Якщо перший операнд повертає True, то and поверне другий операнд:

print([1, 2, 3] and 123)
123

Якщо перший операнд повертає True, то or поверне його:

print([1, 2, 3] or 123)
[1, 2, 3]

Якщо перший операнд повертає False, то or поверне другий операнд:

print([] or 123)
123

13.1.9 Як працює простір імен у Python? Як працює правило LEGB (Local, Enclosing, Global, Built-in)?

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

Розглянемо приклад, в якому повертається локальна змінна str:

str = 'global'

def outer():
    str = 'enclosing'
    
    def inner():
        str = 'local' # локальна змінна
        print(str)
    
    inner()

outer()
local

Якщо цієї змінної не має в локальному просторі імен, то інтерпретатор буде шукати її в зовнішньому просторі імен:

str = 'global'

def outer():
    str = 'enclosing' # зовнішній простір імен
    
    def inner():
        print(str)
    
    inner()

outer()
enclosing

Якщо цієї змінної не має в зовнішньому просторі імен, то інтерпретатор буде шукати її в глобальному просторі імен:

str = 'global' # глобальний простір імен

def outer():

    def inner():
        print(str)
    
    inner()

outer()
global

Якщо цієї змінної не має в глобальному просторі імен, то інтерпретатор буде шукати її в просторі імен вбудованих функцій:

def outer():

    def inner():
        print(str)
    
    inner()

outer()
<class 'str'>

Якщо ми спробуємо звернутися до змінної, якої немає ні в одному з просторів імен, то інтерпретатор викине помилку:

def outer():
    def inner():
        print(variable) # змінна, якої немає ні в одному з просторів імен
    
    inner()

outer()
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Cell In[27], line 7
      3         print(variable) # змінна, якої немає ні в одному з просторів імен
      5     inner()
----> 7 outer()

Cell In[27], line 5, in outer()
      2 def inner():
      3     print(variable) # змінна, якої немає ні в одному з просторів імен
----> 5 inner()

Cell In[27], line 3, in outer.<locals>.inner()
      2 def inner():
----> 3     print(variable)

NameError: name 'variable' is not defined

13.1.10 Як працюють оператори global та nonlocal?

Розглянемо приклад:

a = 1 # глобальна змінна

def outer():    # зовнішня функція
    b = 1       # змінна зовнішньої функції
    
    def inner():    # вкладена функція
        a = 2       # локальна змінна
        b = 2       # локальна змінна

    inner()             # виклик вкладеної функції
    print("a = ", a)    # виведення локальної змінної
    print("b = ", b)    # виведення змінної зовнішньої функції

outer() # виклик зовнішньої функції
a =  1
b =  1

Як бачимо з результату, змінні a та b використовуються відповідно до правила LEGB. Щоб змінити глобальну змінну a в локальній функції inner, потрібно використати оператор global, а для зміни змінної зовнішньої функції b в локальній функції inner потрібно використати оператор nonlocal:

a = 1

def outer():
    b = 1
    
    def inner():
        global a # глобальна змінна
        a = 2

        nonlocal b # змінна зовнішньої функції
        b = 2

    inner()
    print("a = ", a)
    print("b = ", b)

13.1.11 Чи знайомі тобі такі функції, як map, filter, zip?

  • Функція map()може бути застосована до колекції, вона застосовує деяку функцію до кожного елемента нашої колекції:
a = [i for i in range(6)]

b = map(lambda x: x ** 2, a)

print(list(b))
[0, 1, 4, 9, 16, 25]
  • Функція filter() може бути застосована до колекції, вона застосовує деяку функцію до кожного елемента нашої колекції і повертає лише ті елементи, для яких функція повертає True:
a = [i for i in range(6)]

b = filter(lambda x: x % 2 == 0, a)

print(list(b))
[0, 2, 4]
  • Функція zip() приймає декілька колекцій і повертає колекцію кортежів, в яких перший елемент - перший елемент першої колекції, другий елемент - другий елемент другої колекції і так до того моменту, поки не закінчаться елементи в одній з колекцій:
a = [1, 2, 3]
b = [4, 5, 6, 7]
c = [8, 9]

for i in zip(a, b, c):
    print(i)
(1, 4, 8)
(2, 5, 9)

13.1.12 Як оцінюється складність алгоритмів та чому? Що таке Big-O notation?

Алгоритмічна складність оцінюється в Big-O notation.

Big-O notation – це метод оцінки, який визначає, як змінюються витрати виконання залежно від величини вхідних даних.

Примітка

Шпаргалка з Big-O notation: https://www.bigocheatsheet.com/

Рисунок 13.1: Діаграма складності Big-O

13.1.13 Яка алгоритмічна складність основних операцій на колекціях?

Якщо ми говоримо про list, переважно це буде лінійна складність, за виключенням тих операцій, які виконуються наприкінці списку. Якщо ми говоримо про set та dict, то в основному це буде складність за одиницю, тому що set та dict під капотом є хеш-таблицями.

13.2 Функції

13.2.1 Що таке функція та які переваги використання функції?

Функція - це набір інструкцій, які виконуються після її виклику. Функції дозволяють нам уникнути дублювання коду, а також зробити код більш читабельним.

13.2.2 Яким буде результат виконання функції, якщо в ній немає оператора return?

Якщо в функції немає оператора return, то результатом виконання функції буде None:

def func():
    pass

a = func()

print(a)
None

13.2.3 Що таке анотації типів? Навіщо вони потрібні? Коли виконуються анотації типів?

Анотації типів - це спосіб вказати типи аргументів та тип, який повертає функція. Анотації типів не впливають на роботу програми, але допомагають IDE та іншим інструментам аналізувати код та виявляти помилки.

Анотації типів виконуються під час виконання програми, тому їх можна використовувати для написання документації.

def func(a: int, b: int) -> int:
    return a ** b

print(func(2, 3))
8

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

def func(a: int, b: int) -> int:
    return a ** b

print(func('2', '3'))
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[36], line 4
      1 def func(a: int, b: int) -> int:
      2     return a ** b
----> 4 print(func('2', '3'))

Cell In[36], line 2, in func(a, b)
      1 def func(a: int, b: int) -> int:
----> 2     return a ** b

TypeError: unsupported operand type(s) for ** or pow(): 'str' and 'str'

Але якщо я передати float, то вже нічого поганого не станеться, тому що float можна звести в квадрат, хоча це не int:

def func(a: int, b: int) -> int:
    return a ** b

print(func(2.2, 3.3))
13.489468760533386

Крім того є низка винятків. Наприклад, це бібліотека pydentic, яка може використовувати анотації типів у рантаймі для перевірки типу даних, наприклад атрибута класу.

13.2.4 Як Python передає аргументи в функцію?

Є два типи передачі аргументу на функцію: за посиланням та за значенням.

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

Розглянемо приклад:

a = [1, 2, 3]

def func(arg):
    print(arg is a)

func(a)
True

Ми отримали True, тому що arg та a - це один і той же об’єкт.

13.2.5 Що буде, якщо використовувати значення змінного типу як аргумент за замовчуванням функції? І як цього уникнути?

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

def func(a=[]):
    a.append(1)
    return a

print(func())
print(func([]))
print(func())
print(func([]))
[1]
[1]
[1, 1]
[1]

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

Як цього уникнути? Стандартний варіант – це використовувати None, як значення за замовчуванням, тому що це значення незмінного типу. І всередині поставити умову if a is None: a = []. Тобто створювати змінне значення всередині функції, а не в сигнатурі функції:

def func(a=None):
    if a is None:
        a = []
    a.append(1)
    return a

print(func())
print(func([]))
print(func())
print(func([]))
[1]
[1]
[1]
[1]

13.2.6 Що таке *args та **quarks?

*args та **quarks – це спеціальні аргументи, які можна використовувати в сигнатурі функції. *args – це аргументи, які передаються в функцію позиційно. **quarks – це аргументи, які передаються в функцію по ключу.

def func(*args, **quarks):
    print(locals())

func(1, 2, 3, a=1, b=2, c=3)
{'args': (1, 2, 3), 'quarks': {'a': 1, 'b': 2, 'c': 3}}

*args – це кортеж, **quarks – це словник.

13.2.7 Що таке lambda функція та наведіть приклади їх використання?

Функція lambda це функція, яка не має імені. Як правило, це якась коротка функція, яка виконує деяку одну дію, і нам просто не потрібно виділяти для цього окреме ім’я.

Знайдемо ключ у якого найбільше значення в словнику. Якщо ми захочемо використати функцію max() для словника, то вона буде шукати максимальне значення серед ключів, а не серед значень:

a = {
    1: 30,
    2: 20,
    3: 10
}

print(max(a))
3

Тому ми можемо використати lambda функцію, яка буде використовувати значення, а не ключі. Якщо я хочу знайти ключ, у якого найбільше значення, то в max() можна передати ще й key - це може бути лямбда функція, яка буде приймати кожен ключ і повертати значення цього ключа:

print(max(a, key=lambda x: a[x]))
1

13.2.8 Що таке рекурсія? Які обмеження мають рекурсія в Python?

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

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

Приведемо приклад вирішення задачі через рекурсію та цикл, а також виміряємо використанням пам’яті.

Напишемо функцію, яка буде рахувати факторіал числа. Факторіал числа – це добуток всіх чисел від 1 до n. Наприклад, факторіал 5 – це 1 * 2 * 3 * 4 * 5 = 120.

def factorial_rec(n):
    if n == 1:
        return n
    else:
        return n * factorial_rec(n - 1)

print(factorial_rec(5))
120
def factorial_loop(n):
    result = 1
    for i in range(1, n + 1):
        result *= i
    return result

print(factorial_loop(5))
120

Python накладає обмеження на 3000 викликів на рекурсію:

import sys

print(sys.getrecursionlimit())
3000

Це означає, що якщо ми будемо викликати функцію factorial_rec() з аргументом 3001, то ми отримаємо помилку:

print(factorial_rec(3001))
---------------------------------------------------------------------------
RecursionError                            Traceback (most recent call last)
Cell In[47], line 1
----> 1 print(factorial_rec(3001))

Cell In[44], line 5, in factorial_rec(n)
      3     return n
      4 else:
----> 5     return n * factorial_rec(n - 1)

Cell In[44], line 5, in factorial_rec(n)
      3     return n
      4 else:
----> 5     return n * factorial_rec(n - 1)

    [... skipping similar frames: factorial_rec at line 5 (2970 times)]

Cell In[44], line 5, in factorial_rec(n)
      3     return n
      4 else:
----> 5     return n * factorial_rec(n - 1)

RecursionError: maximum recursion depth exceeded

Також за допомогою модуля sys ми можемо встановити обмеження на рекурсію:

import sys

sys.setrecursionlimit(4000)

13.3 Класи

13.3.1 Що таке клас?

Клас - це модель для створення об’єктів певного типу, яка описує їх структуру та поведінку. Клас – це шаблон, за яким створюються об’єкти. Клас – це як план будинку, за яким будується будинок.

13.3.2 Що таке об’єкт класу?

Об’єкт класу – це певна унікальна сутність певного типу, тобто класу, який має структуру та поведінку. Об’єкт – це будинок, який ми будуємо за планом.

13.3.3 Як реалізувати метод об’єкта та що таке self?

Метод об’єкта – це функція, яка виконується в контексті об’єкта. Це означає, що в методі ми можемо використовувати атрибути об’єкта, а також інші методи об’єкта.

self – це посилання на об’єкт, який викликав метод. Це означає, що якщо ми створимо два об’єкти, то в кожному з них буде свій self.

class Person: # клас
    name: str # атрибут класу, анотація типу

    def __init__(self, name): # ініціалізатор класу, який приймає аргумент name
        self.name = name # атрибут об'єкта

    def say_hello(self): # метод класу
        print(f'Hello, my name is {self.name}!') # використання атрибуту об'єкта

p = Person('John')  # створення об'єкта
p.say_hello()       # виклик методу об'єкта
Hello, my name is John!

Методи об’єкта першим аргументом приймають self, який є посиланням на об’єкт, який викликав метод. self – це посилання сам об’єкт, який ми створили.

Data Miorsh Ihor Miroshnychenko Youtube Monobank