Разместить здесь вашу рекламу


Python как предельный случай C++. Часть 2/2

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Продолжение. Начало в «Python как предельный случай C++. Часть 1/2».


Переменные и типы данных


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


В С++ у программиста есть выбор: использовать автоматические переменные, размещаемые в стеке, или держать значения в памяти данных программы, помещая в стек только указатели на эти значения. Что, если мы выберем для Python только одну из этих опций?


Разумеется, мы не можем всегда использовать только значения переменных, так как большие структуры данных не поместятся в стек, либо их постоянное перемещение по стеку создаст проблемы с производительностью. Поэтому мы будем использовать в Python только указатели. Это концептуально упростит язык.


Таким образом, выражение


a = 3

будет означать то, что мы создали в памяти данных программы (так называемой «куче») объект «3» и сделали имя “a” ссылкой на него. А выражение


b = a

в таком случае будет означать, что мы заставили переменную “b” ссылаться на тот же объект в памяти, на который ссылается “a”, иначе говоря − скопировали указатель.


Если всё является указателем, то сколько списочных типов нам нужно реализовать в нашем языке? Разумеется, только один − список указателей! Вы можете использовать его для хранения целых, строк, других списков, чего угодно − ведь всё это указатели.


Сколько типов хэш-таблиц нам нужно реализовать? (В Python этот тип принято называть «словарём» − dict.) Один! Пусть он связывает указатели на ключи с указателями на значения.


Таким образом, нам не нужно реализовывать в нашем языка огромную часть спецификации C++ − шаблоны, поскольку все операции мы производим над объектами, а объекты всегда доступны по указателю. Конечно же, программы, написанные на Python, не обязан ограничиваться работой с указателями: существуют библиотеки вроде NumPy, при помощи которых учёные работают с массивами данных в памяти, как они бы делали это в Fortran. Но основа языка − выражения вроде “a = 3” − всегда работают с указателями.


Концепция «всё является указателем» также упрощает до предела композицию типов. Хотите список словарей? Просто создайте список и поместите туда словари! Не нужно спрашивать у Python разрешения, не нужно объявлять дополнительные типы, всё работает «из коробки».


А что, если мы хотим использовать составные объекты в качестве ключей? Ключ в словаре должен иметь неизменяемое значение, иначе как искать значения по нему? Списки могут изменяться, поэтому их нельзя использовать в данном качестве. Для подобных ситуаций в Python есть тип данных, который, аналогично списку, является последовательностью объектов, но, в отличие от списка, последовательность эта не изменяется. Этот тип называется кортеж или tuple (произносится как «тьюпл» или «тапл»).


Кортежи в Python решают давнюю проблему скриптовых языков. Если вас не впечатляет эта возможность, то вы, наверное, никогда не пытались использовать для серьёзной работы с данными скриптовые языки, в которых в качестве ключа в хэш-таблицах можно использовать только строки или только примитивные типы.


Другая возможность, которую дают нам кортежи − возврат из функции нескольких значений без необходимости объявлять для этого дополнительные типы данных, как это приходится делать в C и C++. Более того, чтобы было проще пользоваться данной возможностью, оператор присваивания был наделён возможностью автоматически распаковывать кортежи в отдельные переменные.


def get_address():
    ...
    return host, port

host, port = get_address()

У распаковки есть несколько полезных побочных эффектов, например, обмен переменных значениями можно записать так:


x, y = y, x

Всё является указателем, значит, функции и типы данных могут использоваться как данные. Если вы знакомы с книгой «Паттерны проектирования» за авторством «Банды четырёх», вы должны помнить, какие сложные и запутанные способы она предлагает для того, чтобы параметризовать выбор типа объекта, создаваемого вашей программой во время выполнения. Действительно, во многих языках программирования это сложно сделать! В Python все эти сложности улетучиваются, поскольку мы знаем, что функция может вернуть тип данных, что и функции, и типы данных − это просто ссылки, а ссылки можно хранить, например, в словарях. Это упрощает задачу до предела.


Дэвид Вилер говорил: «Все проблемы в программировании решаются путём создания дополнительного уровня косвенности». Использование ссылок в Python − это тот уровень косвенности, который традиционно применяется для решения множества проблем во многих языках, в том числе и в C++. Но если там он используется явно, и это приводит к усложнению программ, то в Python он используется неявно, единоообразно в отношении данных всех типов, и дружественно к пользователю.


Но если всё является ссылками, то на что ссылаются эти ссылки? В языках вроде C++ есть множество типов. Давайте оставим в Python только один тип данных − объект! Специалисты в области теории типов неодобрительно качают головами, но я считаю, что один исходный тип данных, от которого производятся все остальные типы в языке − это хорошая идея, обеспечивающая единообразность языка и простоту его использования.


Что касается конкретного содержимого памяти, то различные реализации Python (PyPy, Jython или MicroPython) могут управлять памятью по-разному. Но, чтобы лучше понять, как именно реализуется простота и единообразность Python, сформировать правильную ментальную модель, лучше обратиться к эталонной реализации Python на языке C, называемой CPython, которую мы можем загрузить на сайте python.org.


struct {
    struct _typeobject *ob_type;
    /* followed by object’s data */
}

То, что мы увидим в исходном коде CPython − это структура, которая состоит из указателя на информацию о типе данной переменной и полезной нагрузки, которая определяет конкретное значение переменной.


Как же устроена информация о типе? Снова углубимся в исходный код CPython.


struct _typeobject {
    /* ... */
    getattrfunc tp_getattr;
    setattrfunc tp_setattr;
    /* ... */
    newfunc tp_new;
    freefunc tp_free;
    /* ... */
    binaryfunc nb_add;
    binaryfunc nb_subtract;
    /* ... */
    richcmpfunc tp_richcompare;
    /* ... */
}

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


Это радикальным образом отличается от C и C++, в которых информация о типе ассоциируется с именами, а не со значениями переменных. В Python все имена ассоциированы со ссылками. Значение по ссылке, в свою очередь, имеет тип. В этом и заключается суть динамических языков.


Чтобы реализовать все возможности языка, нам достаточно определить две операции над ссылками. Одна из них наиболее очевидна − это копирование. Когда мы присваиваем значение переменнной, слоту в словаре или атрибуту объекта, мы копируем ссылки. Это простая, быстрая и совершенно безопасная операция: копирование ссылок не изменяет содержимое объекта.


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


У вас может возникнуть вопрос: если все переменные содержат ссылки, то как я могу защитить от изменений значение пременной, передав её функции как параметр?


n = 3
some_function(n)
# Q: I just passed a pointer!
# Could some_function() have changed “3”?

Ответ заключается в том, что простые типы в Python являются неизменяемыми: в них попросту не реализован тот метод, который отвечает за изменение их значения. Неизменяемые (иммутабельные) int, float, tuple или str обеспечивают в языках типа «всё является указателем» тот же семантический эффект, который в C обеспечивают автоматические переменные.


Унифицированные типы и методы максимально упрощают применение обобщённого программирования, или дженериков. Функции min(), max(), sum() и им подобные являются встроенными, нет нужды их импортировать. И они работают с любыми типами данных, в которых реализованы операции сравнения для min() и max(), сложения для sum() и т. д.


Создание объектов


Мы выяснили в общих чертах, как должны вести себя объекты. Теперь определим, как мы будем их создавать. Это − вопрос синтаксиса языка. C++ поддерживает как минимум три способа создания объекта:


  1. Автоматический, объявлением переменной данного класса:
    my_class c(arg);
  2. С помощью оператора new:
    my_class *c = new my_class(arg);
  3. Фабричный, при помощи вызова произвольной функции, возвращающей указатель:
    my_class *c = my_factory(arg);

Как вы уже, наверное, догадались, изучив способ мышления создателей Python на вышеприведённых примерах, теперь мы должны выбрать один из них.


Из той же книги «Банды четырёх» мы узнали, что фабрика − это самый гибкий и универсальный способ создания объектов. Поэтому в Python реализован только этот способ.


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


Другое правило создания объектов в Python таково: любой тип данных является собственной фабрикой. Конечно, вы можете написать сколько угодно дополнительных, кастомных фабрик (которые будут являться обычными функциями или методами, конечно же), но общее правило останется в силе:


# Let’s make type objects
# their own type’s factories!
c = MyClass()
i = int('7')
f = float(length)
s = str(bytes)

Все типы являются вызываемыми объектами, и все они возвращают значения своего типа, определяемые аргументами, переданными при вызове.


Таким образом, с использованием только базового синтаксиса языка, могут быть инкапсулированы любые манипуляции при создании объектов, вроде паттернов «Арена» или «Приспособленец», поскольку ещё одна замечательная идея, позаимствованная из C++, заключается в том, что тип сам определяет, как происходит порождение его объектов, как оператор new работает для него.


Как насчёт NULL?


Обработка пустого указателя добавляет программе сложности, так что мы объявим NULL вне закона. Синтакс Python не даёт возможности создать нулевой указатель. Две элементарные операции над указателями, о которых мы говорили ранее, определены таким образом, что любая переменная указывает на какой-то объект.


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


Вы можете спросить: «Если структура операций над объектами неизменна, как мы видели ранее, то как же пользователи будут создавать собственные классы, с методами и атрибутами, не перечисленными в этой структуре?»


Магия заключена в том, что для пользовательских классов Python имеет очень простую «заготовку» с небольшим числом реализованных методов. Вот самые важные из них:


struct _typeobject {
    getattrfunc tr_getattr;
    setattrfunc tr_setattr;
    /* ... */
    newfunc tp_new;
    /* ... */
}

tp_new() создаёт для пользовательского класса хэш-таблицу, такую же, как для типа dict. tp_getattr() извлекает что-то из этой хэш-таблицы, а tp_setattr(), наоборот, что-то туда кладёт. Таким образом, способность произвольных классов хранить любые методы и атрибуты обеспечивается не на уровне структур языка C, а уровнем выше − хэш-таблицей. (Разумеется, за исключением некоторых случаев, связанных с оптимизацией производительности.)


Модификаторы доступа


Что же нам делать со всеми теми правилами и концепциями, которые в C++ построены вокруг ключевых слов private и protected? Python, будучи скриптовым языком, не нуждается в них. У нас уже есть «защищённые» части языка − это данные встроенных типов. Ни при каких условиях Python не позволит программе, например, манипулировать битами числа с плавающей запятой! Этого уровня инкапсуляции вполне достаточно, чтобы поддержать целостность самого языка. Мы, создатели Python, считаем, что целостность языка − это единственный хороший предлог для сокрытия информации. Все остальные структуры и данные пользовательской программы считаются публичными.


Вы можете написать символ подчёркивания (_) в начале имени атрибута класса, чтобы предупредить коллегу: на этот атрибут не стоит полагаться. Но в остальном Python выучил уроки начала 90-х: тогда многие верили в то, что основной причиной того, что мы пишем раздутые, нечитаемые и забагованные программы, является недостаток приватных переменных. Думаю, следующие 20 лет убедили всех в индустрии программирования: приватные переменные − это не единственное, и далеко не самое эффективное средство от раздутых и забагованных программ. Поэтому создатели Python решили даже не беспокоиться по поводу приватных переменных, и, как видите, не прогадали.


Управление памятью


Что же происходит с нашими объектами, числами и строками на более низком уровне? Как именно они размещаются в памяти, как CPython обеспечивает совместный доступ к ним, когда и при каких условиях они уничтожаются?


И в этом случае мы выбрали наиболее общий, предсказуемый и производительный способ работы с памятью: со стороны C-программы все наши объекты − это разделяемые указатели.


С учётом этого знания те структуры данных, которые мы рассмотрели ранее, в части «Переменные и типы данных», должны быть дополнены следующим образом:


struct {
    Py_ssize_t ob_refcnt;
    struct {
       struct _typeobject *ob_type;
        /* followed by object’s data */
    }
}

Итак, каждый объект в Python (мы имеем в виду реализацию CPython, разумеется) имеет свой счётчик ссылок. Как только он становится равным нулю, объект может быть удалён.


Механизм подсчёта ссылок не опирается на дополнительные вычисления или фоновые процессы − объект может быть уничтожен мгновенно. Кроме того, он обеспечивает высокую локальность данных: зачастую память снова начинает использоваться сразу после освобождения. Только что уничтоженный объект, скорее всего, недавно использовался, а значит, находился в кэше процессора. Поэтому и только что созданный объект останется в кэше. Эти два фактора − простота и локальность − делают подсчёт ссылок очень производительным способом сборки мусора.


(Из-за того, что объекты в реальных программах нередко ссылаются друг на друга, счётчик ссылок в определённых случаях не может опуститься до нуля, даже когда объекты больше не используются в программе. Поэтому в CPython есть и второй механизм сбора мусора − фоновый, основанный на поколениях объектов. − прим. перев.)


Ошибки разработчиков Python


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


Python 2 из-за инерции мышления, связанной со скриптовыми языками, пытался преобразовывать строковые типы, как делал бы это язык с нестрогой типизацией. Если вы попытаетесь объединить байтовую строку со строкой в Unicode, интерпретатор неявно преобразует байтовую строку в Unicode при помощи той кодовой таблицы, которая имеется в данной системе, и представит результат в Unicode:


>>> 'byte string ' + u'unicode string'
u'byte string unicode string'

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


Эта ошибка проектирования языка была исправлена в Python 3:


>>> b'byte string ' + u'unicode string'
TypeError: can't concat bytes to str

Похожая ошибка в Python 2 была связана с «наивной» сортировкой списков, состоящих из несравнимых элементов:


>>> sorted(['b', 1, 'a', 2])
[1, 2, 'a', 'b']

Python 3 в этом случае даёт пользователю понять, что тот пытается сделать что-то не слишком осмысленное:


>>> sorted(['b', 1, 'a', 2])
TypeError: unorderable types: int() < str()

Злоупотребления


Пользователи и сейчас иногда злоупотребляют динамической природой языка Python, а тогда, в 90-х, когда лучшие практики ещё не были широко известны, это происходило особенно часто:


class Address(object):
    def __init__(self, host, port):
         self.host = host
         self.port = port

«Но это же неоптимально!» − говорили некоторые, − «Что, если порт не отличается от дефолтного значения? Мы всё равно тратим на его хранение целый атрибут класса!» И в результате получалось что-то вроде


class Address(object):
    def __init__(self, host, port=None):
        self.host = host
        if port is not None:  # so terrible
            self.port = port

Так в программе появляются объекты одного типа, с которыми, тем не менее, нельзя работать единообразно, так как одни из них имеют некий атрибут, а другие − нет! И мы не можем прикоснуться к этому атрибуту, не проверив заранее его наличие:


# code was forced to use introspection
# (terrible!)
if hasattr(addr, 'port'):
    print(addr.port)

В настоящее время обилие hasattr(), isinstance() и прочей интроспекции является верным признаком плохого кода, а лучшей практикой считается делать атрибуты всегда присутствующими в объекте. Это обеспечивает более простой синтаксис при обращении к нему:


# today’s best practice:
# every atribute always present
if addr.port is not None:
    print(addr.port)

Так, ранние эксперименты с динамически добавляемыми и удаляемыми атрибутами завершились, и теперь мы рассматриваем классы в Python примерно так же, как и в C++.


Другой дурной привычкой раннего Python было использование функций, в которых аргумент может иметь совершенно разные типы. Например, вы можете подумать, что для пользователя может быть слишком сложно создавать каждый раз список с именами колонок, и сто́ит разрешить ему передавать их также в виде одной строки, где имена отдельных колонок разделены, скажем, запятой:


class Dataframe(object):
    def __init__(self, columns):
        if isinstance(columns, str):
            columns = columns.split(',')
        self.columns = columns

Но такой подход может породить свои проблемы. Например, что, если пользователь случайно передаст нам строку, которая не предназначена для того, чтобы быть использована как список имён колонок? Или если имя колонки должно содержать запятую?


Также такой код сложнее поддерживать, отлаживать, и особенно тестировать: в тестах может быть предусмотрена проверка только одного из двух поддерживаемых нами типов, но покрытие всё равно составит 100%, и мы не протестируем другой тип.


В итоге мы пришли к тому, что Python даёт пользователю возможность передавать функции аргументы какого угодно типа, но большинство из них в большинстве ситуаций будут использовать функцию так же, как они делали бы это в C: передавать ей аргумент одного типа.


Необходимость использования eval() в программе считается явным архитектурным просчётом. Скорее всего, вы просто не сообразили, как сделать то же самое нормальным способом. Но в некоторых случаях − например, если вы пишете программу типа Jupyter notebook или онлайн-песочницу для запуска и тестирования пользовательского кода − использование eval() вполне оправдано, и в этом типе задач Python проявляет себя великолепно! Действительно, реализовать нечто подобное на C++ было бы намного сложнее.


Как мы уже показали выше, интроспекция (getattr(), hasattr(), isinstance()) не всегда является хорошим средством для выполнения типичных пользовательских задач. Но эти возможности, тем не менее, встроены в язык, и они просто сверкают в ситуациях, когда наш код должен описывать сам себя: логгирование, тестирование, статическая проверка, отладка!


Эра консолидации


В заключение мне хочется отметить следующее: мы живём в такое время, когда лучшие практики разработки на различных языках проявляют тенденцию к консолидации. 20 лет назад я не смог бы даже упомянуть разделяемые указатели в контексте того, что объединяет C++ и Python. А сегодня сообщества, сформировавшиеся вокруг разных языков программирования, свободно обмениваются лучшими практиками. И это изменение произошло в течение девяностых и нулевых.


Чтобы получить количественные измерения в подтверждение моей гипотезы, я мониторил использование shared_ptr в TensorFlow примерно с 2016 по 2018 год.


TensorFlow − это большой и во многом образцовый C++-проект, но большинство программистов знают его лишь в качестве Python-библиотеки (а C++ − в качестве сборочной системы TensorFlow, наверное).


image


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


Так куда же направляется современный C++? В начале мы говорили о предельных случаях. Что происходит на графике, который мы видим? Если время стремится к бесконечности, то всё становится разделёнными указателями, и C++ становится Python!

Источник: https://habr.com/ru/post/464405/


Интересные статьи

Интересные статьи

Во время изучения различных алгоритмов машинного обучения я наткнулся на ландшафт потерь нейронных сетей с их горными территориями, хребтами и долинами. Эти ландшафты потерь сильно отлича...
Здравствуй Хабр! Прежде всего я хотел бы сказать спасибо всем читателям, присоединившимся в комментариях к первой части. Честно сказать, я не ожидал, что моя статья получит подобный от...
Сегодня для решения множества прикладных задач требуется возможность генерировать случайные числа. Очевидно, что в зависимости от того, какая конкретно задача решается, к...
Это часть 1 из серии 2 частей практического руководства по HashiCorp Consul. Эта часть в первую очередь ориентирована на понимание проблем, которые решает Consul и как он их решает. Вто...
Как и обещали, продолжаем рассказывать про освоение процессоров Эльбрус. Данная статья является технической. Информация, приведенная в статье, не является официальной документацией, ведь получена...