Продвинутые советы по Python, которые вы должны использовать, но, вероятно, не используете.

Темы:

  1. Основы программирования: ведение журнала, подсказка типов, генераторы, анализ аргументов и декораторы.
  2. Поведение класса: наследование, Dunders, перегрузка операторов, инкапсуляция и абстрактные классы.
  3. Введение в шаблоны проектирования: Singleton, Factory, Proxy и Composit.

Основы программирования

Ведение журнала

При мониторинге и отладке большинство программистов-самоучек начинают либо с написания собственного модуля логгера, либо (не дай бог) просто печатают все, что только можно вообразить.

Использование встроенных logging и (pip устанавливаемых) coloredlogs может выполняться с минимальными усилиями.

Напомним, что существует 5 уровней безопасности при ведении журнала:

DEBUG:       - for devs, troubleshoot and testing.
INFO:        - informational about current state.
WARNING      - system doesn't crash but could cause a problem. Example: RAM limit approaching.
ERROR        - couldn't do some operation, but the system can still runs.
CRITICAL     - the entire system is impaired.

Изменение уровня безопасности будет определять, сколько скрипт будет регистрировать в консоли/записывать в обработчик. Реализация регистратора состоит из handler (который записывает файл журнала); formatter (необязательно, для форматирования вывода); и logger instance.

import logging, coloredlogs

# configuration
formatter   = logging.Formatter("[%(asctime)s] [%(levelname)8s] --- %(message)s (%(filename)s:%(lineno)s)", "%Y-%m-%d %H:%M:%S")
logger      = logging.getLogger('My logger.')
handler     = logging.FileHandler('mylogs.log')
handler.setLevel(logging.DEBUG)
handler.setFormatter(formatter)
logger.addHandler(handler)
coloredlogs.install(level='DEBUG', logger=logger)

# examples
logger.debug("this is a DEBUG message")
logger.info("this is an INFO message")
logger.warning("this is a WARNING message")
logger.error("this is an ERROR message")
logger.critical("this is a CRITCAL message")

Подсказка типа

Python имеет динамическую типизацию. Однако часто полезно следить за типом переменных. Type hinting используется регулярно, но не вызывает проблем с компиляцией, поэтому его можно игнорировать. Условные операторы можно использовать для записи исключений или обработки нежелательных types или returns во время выполнения, но это может излишне раздувать код.

mypy — это пакет, предназначенный для решения этой проблемы во время разработки. Аргументы функции и возвращаемое значение types можно добавить стандартным образом. Затем мы запускаем скрипт из терминала, используя mypy script.py (в отличие от python script.py) — автоматически проверяя, соответствуют ли types их ожиданиям.

Например, приведенный ниже код компилируется без ошибок, но функция ошибочно передает string вместо ожидаемого int.

def func(arg_1:int, arg_2:list[int]) -> tuple:
    return arg_1, arg_2

func('12', [1,2,3])

Во время разработки можно было запустить скрипт с mypy script.py, поймав ошибку:

Генераторы

Генераторы используют «ленивое выполнение», чтобы избежать выполнения больших итераций до тех пор, пока они не потребуются. Слишком знакомая функция range() является примером генератора. То есть он не вычисляет полный диапазон элементов и не сохраняет их в памяти, а сохраняет саму функцию (с «состоянием»), а затем может генерировать следующий шаг в последовательности по мере необходимости.

Это можно увидеть, просмотрев размер (в байтах) двух экземпляров range(): первый содержит один элемент, второй — 1 миллион элементов. Сравните размер генерации r с размером соответствующего списка l.

import sys

# range items (generators)
r1 = range(1)
r2 = range(1000000)

# convert to list 
l1 = list(r1)
l2 = list(r2)

def ptr(obj, name):
    print(f'object:{name} is {sys.getsizeof(obj):>7} bytes.')

ptr(r1, 'r1')
ptr(r2, 'r2')
ptr(l1, 'l1')
ptr(l2, 'l2')

Во многих случаях может оказаться выгодным написать собственный генератор. Этого можно добиться с помощью оператора yeild в python. Генераторы работают бесконечно, делая возможными бесконечные последовательности. Например, вот генератор, который возвращает силы x навечно.

def power_x(x):
    y = 0
    while True:
        yield x**y
        y += 1

value = power_x(4)
print(next(value))
print(next(value))
print(next(value))
print(next(value))
print(next(value))

Разбор аргумента

Нам часто нужно выполнить сценарий из командной строки, и мы можем захотеть включить аргументы. sys.argv возвращает список аргументов, переданных сценарию (где sys.argv[0] — имя файла).

Предположим, мы хотим использовать необязательные аргументы, а не простой список позиционных аргументов. Встроенный пакет getopt можно использовать для включения необязательных аргументов. Например:

import getopt
import sys

opts, args = getopt.getopt(
    sys.argv[1:],
    'f:m:',
    ['filename','message']
    )

print('opt:     ', opts)
print('args:    ', args)

Декораторы

Как следует из названия, декораторы используются для decorate функций (добавления дополнительных функций). Они принимают форму:

def decorator(function):
    def wrapper(*args, **kwargs):
        # add decorations
        rt = function(*args, **kwargs)
        return rt
    return wrapper

@decorator
def function(x):
    # some function
    pass

Где некоторая дополнительная логика может содержаться в функции decorator.wrapper(). Строка @decorator над функцией эквивалентна вызову:

decorator(function)(*args, **kwargs)

Например, мы можем создать декоратор, который измеряет время функции:

import time

def timer_decorator(function):
    def wrapper(*args, **kwargs):
        start   = time.time()
        rt      = function(*args, **kwargs)
        end     = time.time()
        print(f'function: {function.__name__} was executed in {end-start} seconds.')
        return rt
    return wrapper


@timer_decorator
def funct(x):
    a = 1
    for i in range(1,x):
        a *= i
    return a

funct(10000)

Поведение класса

Наследование

Наследование (включено для полноты) позволяет дочернему классу наследовать все свойства родительского класса. Например, класс Ferrari может иметь все свойства класса car, но также содержать отдельные свойства.

# Inheretence
class Car:
    def __init__(self, type, age) -> None:
        self.type = type
        self.age  = age

class Ferrari(Car):
    def __init__(self, type, age, speed) -> None:
        super(Ferrari, self).__init__(type, age)
        self.speed = speed

Переменные класса

Цель ООП-программирования — объединить данные и операции в объекты (классы). Конкретный экземпляр класса содержит определенные данные, которые определяют экземпляр. Также возможно связать данные с объектом уровня class. Мы можем захотеть отслеживать определенные данные по всем экземплярам типа класса.

class Base:
    count = 0
    def __init__(self) -> None:
        Base.count += 1

[Base() for _ in range(5)]
print('Instances: ', Base.count)

Статические методы.тот же принцип можно применить к методам, определив статические методы. Это методы, которые могут быть классифицированы в классе напрямую без создания экземпляра.

class Base:

    @staticmethod
    def mymethod():
        # some function
        pass

Base.mymethod()

Дандерс

Методы Dunder (двойное подчеркивание) определяют встроенное поведение. Стандартное поведение можно переопределить, включив методы dunder в определение класса. Конструктор (__init__), деструктор (__del__) и приведение строкового типа (__str__) являются типичными примерами этого.

Важно отметить, что если вы хотите получить доступ к поведению dunder по умолчанию, можно использовать super().__dunder__(). Другим недоиспользуемым дандером является метод __call__, который делает объект вызываемым. Более конкретно,

class Example:
    def __str__(self) -> str:
            txt = super().__str__()
            txt += ' - added to text...'
            return txt

    def __call__(self, *args, **kwds):
        print(f'\n{self.__class__.__name__} was called!')

ex = Example()
print(ex)
ex()

Перегрузка оператора

Особый случай dunders, Перегрузка оператора позволяет нам определить, как объекты взаимодействуют с операторами (+,-,*,÷). Многие формы программирования (включая мою родную науку о данных) по своей природе являются математическими. Переопределение поведения оператора по умолчанию можно использовать, чтобы указать, как должны взаимодействовать объекты класса. Рассмотрим оператор + (модифицированный с помощью __add__ dunder):

class Vector:
    def __init__(self,x,y) -> None:
        self.x, self.y = x, y

    def __add__(self, other):
        return Vector(self.x+other.x, self.y+other.y)

    def __str__(self) -> str:
        return '    x:{}, y:{}'.format(self.x, self.y)

v1 = Vector(1,2)
v2 = Vector(5,4)
v3 = v1 + v2
print(v1)
print(v2)
print(v3)

Инкапсуляция

Иногда мы хотим скрыть или ограничить некоторую информацию о классе. Предположим, у нас есть класс Person со свойством age. Соглашение об именовании с двойным хранилищем делает переменную частной:

class Person:
    def __init__(self, age) -> None:
        self.__age = age

В результате мы не можем изменить переменную age на более позднем этапе:

p1 = Person()
p1.__age

Мы можем добавить property к классу, который позволит нам взаимодействовать с этой переменной.

@property
    def Age(self):
        return self.__age

Затем мы можем добавить метод Property.setter (используя то же имя метода) для обновления свойства. Если сложить все вместе, получится:

class Person:
    def __init__(self, age) -> None:
        self.__age = age

    @property
    def Age(self):
        return self.__age
    
    @Age.setter
    def Age(self, value):
        if value <= 0:
            return
        else:
            self.__age = value


p1 = Person(20)
print(p1.Age)

p1.Age = -2342
print(p1.Age)

p1.Age = 2342
print(p1.Age)

Обратите внимание, что мы запретили возрасту устанавливать отрицательное значение. Таким образом мы можем контролировать и ограничивать свойства класса.

Абстрактные классы

Абстрактные классы, определенные как классы, которые не могут быть созданы, определяют схемы для создания согласованных классов. Предположим, мы хотим определить набор классов, реализующих один и тот же набор методов. Все подклассы могут наследовать от абстрактного класса. Таким образом, если какой-либо подкласс не сможет реализовать требуемый метод, будет возбуждено исключение.

from abc import ABCMeta, abstractstaticmethod
class IAbstractClass(metaclass=ABCMeta):
    @abstractstaticmethod
    def method_01():
        pass
class child_class(IAbstractClass):
    @staticmethod
    def method_01():
        print('required method implemented!')

Если бы method_01() не был реализован в дочернем_классе, возникло бы исключение компиляции.

Для повышения надежности системы при определении шаблонов проектирования часто используются абстрактные классы.

Введение в шаблоны проектирования

Хорошая разработка программного обеспечения заключается в разработке надежных систем во время выполнения. Это требует продуманных дизайнерских решений как на уровне архитектуры, так и на уровне кода.

Хотя это ни в коем случае не является исчерпывающим, вот несколько (Gang of 4) шаблонов проектирования программного обеспечения для улучшения вашего кода. Подробнее о шаблонах проектирования читайте на Refactoring.Guru.

Синглтон

Как следует из названия, шаблон проектирования Singleton добавляет к объекту класса ограничение, позволяющее создать его экземпляр только один раз. Замечательно сформулировано Refactoring.Guru, это может быть необходимо для обеспечения того, чтобы переменная не была перезаписана или чтобы ограничить доступ к общим ресурсам.

Ключом является переменная класса Singleton: __instance. При первом создании экземпляра одноэлементного класса для него устанавливается значение self. Таким образом, Singleton инициализируется во второй раз, будет возбуждено исключение.

from abc import ABCMeta, abstractstaticmethod

class IAbstractClass(metaclass=ABCMeta):

    @abstractstaticmethod
    def print_data():
        pass

class Singleton(IAbstractClass):
    __instance = None

    def __init__(self) -> None:
        if Singleton.__instance is not None:
            raise Exception('Singleton cannot be instantiated more than once.')
        super().__init__()
        Singleton.__instance = self

    @staticmethod
    def get_instance():
        if Singleton.__instance is not None:
            return Singleton.__instance
        return 'No instance of Singleton.'

    @staticmethod
    def print_data():
        print(f'{Singleton.__instance}')

Фабрика

Предположим, мы хотим отложить категоризацию класса до тех пор, пока не будет получена дополнительная информация (некоторые расчеты, данные или пользовательский ввод). Фабричный метод задерживает указание определения класса, используя класс Factory для создания подклассов.

При реализации фабричного шаблона проектирования мы реализуем класс Factory, который строит желаемый подкласс с учетом некоторых данных. Следуя методологии абстрактного класса, шаблон проектирования factory может быть реализован следующим образом:

from abc import ABCMeta, abstractstaticmethod

class IAbstractClass(metaclass=ABCMeta):
    @abstractstaticmethod
    def print_class_type():
        """Interface Method"""
        pass

class subclass_01(IAbstractClass):
    def __init__(self) -> None:
        super().__init__()
    def print_class_type(self):
        print('Class 01')

class subclass_02(IAbstractClass):
    def __init__(self) -> None:
        super().__init__()
    def print_class_type(self):
        print('Class 02')

class Factory:
    @staticmethod
    def build_subclass(class_type):
        if class_type == 'a':
            return subclass_01()
        return subclass_02()

if __name__ == '__main__':
    choice = input('Type of class?\n')
    person = Factory.build_subclass(choice)
    person.print_class_type()

Прокси

Как и в случае с декораторами, шаблон проектирования прокси оборачивает дополнительный уровень функциональности вокруг класса. Вероятно, увеличить модульность или отложить выполнение основного класса до тех пор, пока не будет доступно больше ресурсов. Прокси — это посредник, который занимается реализацией основного класса.

from abc import ABCMeta, abstractstaticmethod
class IAbstractClass(metaclass=ABCMeta):
    
    @abstractstaticmethod
    def class_method():
        """Implement in class."""

class CoreClass(IAbstractClass):
    def class_method(self):
        print('Core class executed...')

class ProxyClass(IAbstractClass):
    def __init__(self) -> None:
        super().__init__()
        self.core = CoreClass()
    def class_method(self):
        print('Proxy class executed...')
        self.core.class_method()

Композит

Интуитивно понятно, что составной класс — это просто набор подклассов, образующих более крупный класс. Естественно вписываясь в большую часть того, как организован корпоративный мир, он интуитивно понятен и прост в реализации.

from abc import ABCMeta, abstractmethod, abstractstaticmethod

class IAbstractModule(metaclass=ABCMeta):
    @abstractmethod
    def __init__(self, nodes) -> None:
        """Implement in child class"""

    @abstractstaticmethod
    def print_data():
        """Implement in child class"""

class child_01(IAbstractModule):
    def __init__(self, nodes) -> None:
        self.nodes = nodes
        
    def print_data(self):
        print('child 01 has {} nodes.'.format(self.nodes))

class child_02(IAbstractModule):
    def __init__(self, nodes) -> None:
        self.nodes = nodes
        
    def print_data(self):
        print('child 02 has {} nodes.'.format(self.nodes))

class ParentClass(IAbstractModule):
    def __init__(self, nodes) -> None:
        # super().__init__(nodes)
        self.nodes      = nodes
        self.base_nodes = nodes
        self.children   = []
    
    def add_child(self, child):
        self.children.append(child)
        self.nodes += child.nodes
    
    def print_data(self):
        s = '\n'+'...'*10; print(s)
        print('Core nodes:  {}'.format(self.base_nodes))
        for chd in self.children:
            chd.print_data()
        print('Total nodes: {}'.format(self.nodes))
        s = '...'*10+'\n'; print(s)

# implement
c1 = child_01(21)
c2 = child_02(43)
p1 = ParentClass(6)
p1.add_child(c1)
p1.add_child(c2)
p1.print_data()

Фин 👋.