Пишем Brainfuck на Python

Есть мнение, что каждый программист должен написать в жизни хотя бы один язык программирования, либо реализовать уже существующий, но своими руками. В качестве примера мы начнем с самого простого и создадим Brainfuck. Согласно Вики:

Brainfuck — один из известнейших эзотерических языков программирования, придуман Урбаном Мюллером (нем.Urban Müller) в 1993 году, известен своим минимализмом. Название языка можно перевести на русский как вынос мозга, оно напрямую образовано от английского выражения brainfuck (brain — мозг, fuck — иметь половое сношение), т. е. заниматься ерундой. Язык имеет восемь команд, каждая из которых записывается одним символом. Исходный код программы на Brainfuck представляет собой последовательность этих символов без какого-либо дополнительного синтаксиса.

Википедия.

Описание 8 команд:

Команда BrainfuckОписание команды
Начало программывыделяется память под 30 000 ячеек с нулевыми начальными значениями
>перейти к следующей ячейке
<перейти к предыдущей ячейке
+увеличить значение в текущей ячейке на 1
-уменьшить значение в текущей ячейке на 1
.напечатать значение из текущей ячейки
,ввести извне значение и сохранить в текущей ячейке
[если значение текущей ячейки ноль, перейти вперёд по тексту программы на ячейку, следующую за соответствующей ] (с учётом вложенности)
]если значение текущей ячейки не нуль, перейти назад по тексту программы на символ [ (с учётом вложенности)

Реализация интерпретатора на Python

Интерпретатор читает программу и выполняет ее сразу на лету.

Первым делом надо прочитать целиком файл с исходником на BF, имя которого мы передаем первым параметром командной строки:

if __name__ == '__main__':
    if len(sys.argv) == 2:
        # один аргумент - имя файла, который надо выполнить
        with open(sys.argv[1], 'r') as f:
            code = f.read()
        run(code)  # запустить код
    else:
        print(f'usage: python {sys.argv[0]} file.bf')

После того, как код прочитан, мы создаем память из 30 000 ячеек, заполненными нулями. Устанавливаем счетчик команд и указатель на ленте ячеек на 0. Инициализация имеет такой вид:

mem_size = 30000
memory = [0] * mem_size  # оперативная память программы
data_ptr = 0  # индекс текущей ячейки памяти (memory)
instr_ptr = 0  # индекс текущей инструкции кода (code)

Далее в цикле читаем ячейку, если это одна из наших 8 команд, то выполняем, иначе пропускаем (это могут быть пробелы и переносы строк, их просто игнорируем). После выполнения команды, указатель сдвигается вправо по коду (увеличивается на единицу). Программа выполняется до тех пор, пока счетчик инструкций не выйдет за пределы размера кода.

С командами типа «+», «-«, «<«, «>» проблем в понимании быть не должно, они делают простые действия. Разве что, вам следует обратить внимание на то, что я зацикливаю оперативную память, если команда смещения переходит границу (команда < от 0 сдвинет на 29999 ячейку, а команда > с ячейки номер 29999 сдвигает на 0 ячейку).

Фрагмент цикла:

# выполняем - пока не вылетели за пределы кода
while instr_ptr < len(code):
    command = code[instr_ptr]  # текущая команда

    if command == '+':
        memory[data_ptr] += 1
    elif command == '-':
        memory[data_ptr] -= 1
    elif command == '>':
        data_ptr = (data_ptr + 1) % mem_size  # циклическая память
    elif command == '<':
        data_ptr = (data_ptr - 1) % mem_size  # циклическая память
    ...
    instr_ptr += 1   # сдвигаеи к следующей команде

Команда точка «.» печатает на экране единственный символ из текущий ячейки памяти. Я использую функцию chr, чтобы из числа получить символ. end='' указывает, что не надо переводить строку. Команда запятая «,» вводит символ из потока ввода. Считываю строку, беру первый символ и получаю его код функцией ord. Дополним код цикла обработкой ввода и вывода:

...
elif command == '.':
    # печатаем символ с кодом = значения текущей ячейки
    print(chr(memory[data_ptr]), end='')
elif command == ',':
    # ввод - берем код первого символа или 0, если ввод пустой
    inp = input()
    memory[data_ptr] = ord(inp[0]) if inp else 0
...

Это еще не все. Осталось самое сложное – обработка циклов (они же играю роль ветвлений в BF). Открывающая квадратная скобка «[« в code – команда, говорит нам, что нужно проверить текущую ячейку памяти в memory на равенство нулю. Если нуль, то прыгаем за пределы цикла – на соответствующую закрывающую скобку «]». Если не нуль, то дальше просто выполняем тело цикла. Когда натакливаемся на «]», тут тоже нужно проверить условие цикла, и если оно даст не ноль – вернуться к началу цикла, а иначе продолжить выполнять код дальше за скобкой.

Каждый раз во время выполнения кода искать нужную скобку – неудобно и медленно. Можно оптимизировать код, заранее пройдя по нему и запомнив, где какая скобка и где ее подруга – парная скобка. Для этого заведем словарь bracket_map. Для учета вложенности будем пользоваться структурой данных – стэк (из обычного списка):

# ключ и значения - индекс байта в коде
# по любой скобке находим ей пару
bracket_map = {}
stack = []
for pos, symbol in enumerate(code):
    # открыли скобку - положим на вершину стэка ее позицию
    if symbol == '[':
        stack.append(pos)
    elif symbol == ']':
        # вершина стэка - как раз наша парная скобка - была последней
        # удалим ее с вершины
        last_open_pos = stack.pop()
        # создадим парные записи
        bracket_map[pos] = last_open_pos
        bracket_map[last_open_pos] = pos

На иллюстрации ниже показано, что хранит этот словарь на примере простой программы.

Схема скобок для Hello world

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

...
elif command == '[':
    # начало цикла - проверка текущей ячейки
    if not memory[data_ptr]:  # == 0
        # значит надо перейти на ячейку за соответствующей ей закрывающей скобкой
        instr_ptr = bracket_map[instr_ptr]
elif command == ']':
    # проверяем тоже условие, если выполняется, то скачем на начало цилка
    if memory[data_ptr]:  # не ноль
        # перемещаем на конец цикла
        instr_ptr = bracket_map[instr_ptr]

Готово! Интерпретатор завершен! Выполним простую программу hello_world.bf:

 ++++++++++[>+++++++>++++++++++>+++>+<<<<-]>++
 .>+.+++++++..+++.>++.<<+++++++++++++++.>.+++.
 ------.--------.>+.>.
(venv) ➜  bf python bf_interpretator.py hello_world.bf
Hello World! 

Работает! А вот как вам сортировка вставками на Brainfuck:

>>+>,[
    <[
        [>>+<<-]>[<<+<[->>+[<]]>>>[>]<<-]<<<
    ]>>[<<+>>-]<[>+<-]>[>>]<,
]<<<[<+<]>[>.>]

Запускаем. Пишем по очереди символы по одному, энтер после каждого. Чтобы завершить ввод массива – просто энтер без символа:

(venv) ➜  bf python bf_interpretator.py isort.bf      
8
4
6
1
9

14689  

Код программы и примеры загрузил на gist.

Компилятор Brainfuck на Python

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

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

PRELUDE = """// brainfuck generated:
#include <stdio.h>
int main() {
    const int mem_size = 30000;
    char mem[mem_size];
    memset(mem, 0, mem_size);
    int cur = 0;
"""

ENDING = """
    return 0;
}
"""

Далее код компилятора BF в Си. Все по таблице из Википедии:

def compile_to_c(bf_code):
    instr_ptr = 0  # индекс текущей инструкции кода (code)

    indent = 4  # смещение пробелами - для красоты
    c_code = PRELUDE

    # выполняем - пока не вылетели за пределы кода
    for command in bf_code:
        content = ''
        if command == '+':
            content = 'mem[cur]++;'
        elif command == '-':
            content = 'mem[cur]--;'
        elif command == '>':
            content = 'cur++;'
        elif command == '<':
            content = 'cur--;'
        elif command == '.':
            content = 'putchar(mem[cur]);'
        elif command == ',':
            content = 'mem[cur] = getchar();'
        elif command == '[':
            content = 'while(mem[cur]) {'
        elif command == ']':
            content = '}'

        instr_ptr += 1  # сдвигаем к следующей команде

        if content:
            if command == ']':
                indent -= 4
            c_code += ' ' * indent + content + '\n'
            if command == '[':
                indent += 4

    c_code += ENDING
    return c_code

Сохраняем С-код на диск и вызывем компилятор cc:

c_code = compile_to_c(code)
c_file = f'{name}.c'
with open(c_file, 'w') as f:
    f.write(c_code)
os.system(f'cc {c_file} -o {name}.out')

Проверка:

(venv) ➜  bf python bf_compiler.py hello_world.bf
...тут какое-то предупреждение от компилятора...

(venv) ➜  bf ./hello_world.bf.out 
Hello World!

Код на Си получится примерно такой (я сократил его значительно, чтобы не занимать много места в статье):

// brainfuck generated:
#include <stdio.h>
int main() {
    const int mem_size = 30000;
    char mem[mem_size];
    memset(mem, 0, mem_size);
    int cur = 0;
    mem[cur]++;
    mem[cur]++;
    mem[cur]++;
    mem[cur]++;
    mem[cur]++;
    while(mem[cur]) {
        cur++;
        mem[cur]++;
        mem[cur]++;
        mem[cur]++;
        cur++;
        mem[cur]++;
        cur--;
        cur--;
        cur--;
        cur--;
        mem[cur]--;
    }
    cur++;
    mem[cur]++;
    mem[cur]++;
    putchar(mem[cur]);
    cur++;
    mem[cur]++;
    putchar(mem[cur]);
    mem[cur]++;
    mem[cur]++;
    mem[cur]++;
    mem[cur]++;
    mem[cur]++;
    mem[cur]++;
    putchar(mem[cur]);
    cur++;
    putchar(mem[cur]);

    return 0;
}

Код компилятора в том же самом gist.

Замечания. В это варианте запятая будет работать не так, как в интерпретаторе выше. Поэтому код с сортировкой будет вести себя иначе. И второе: проверял компилятор на своей системе macOS, на Windows компилятор скорее всего не будет работать, если только вы его не запускаете из подсистемы Linux или Cygwin или подобных.

Специально для канала @pyway. Подписывайтесь на мой канал в Телеграм @pyway 👈 

Пишем файл Excel из Python

Если вдруг вам потребуется, к примеру, выгружать отчеты из вашей программы, почему бы не воспользоваться общепринятым офисным форматом – Excel? В этом нет ничего сложного, потому что есть прекрасная библиотека XlsxWriter. Приведу для вас немного примеров из документации с собственными дополнениями. Итак, поехали с установки:

pip install XlsxWriter

Простейший пример, думаю, не вызовет вопросов, если вы знакомы с Excel: открыли файл, добавили лист, записали по адресу ячейки текст:

import xlsxwriter

# открываем новый файл на запись
workbook = xlsxwriter.Workbook('hello.xlsx')

# создаем там "лист"
worksheet = workbook.add_worksheet()

# в ячейку A1 пишем текст
worksheet.write('A1', 'Hello world')

# сохраняем и закрываем
workbook.close()

Сразу отмечу, что можно адресовать ячейки не только по строке типа А1 или C15, а непосредственно по индексам колонки и строки, но нумерация начинается в таком случае с нуля (0).

worksheet.write(0, 0, 'Это A1!')
worksheet.write(4, 3, 'Колонка D, стока 5')

Формулы

Естественно, мы можем добавить в ячейки формулы, как мы делаем это руками в Excel – нужно начать выражение со знака равно (=). Пример: в конце таблицы трат введем подсчет суммы:

import xlsxwriter

workbook = xlsxwriter.Workbook('formula.xlsx')
worksheet = workbook.add_worksheet()

# данные
expenses = (
    ['Аренда', 1000],
    ['Комуналка', 100],
    ['Еда', 300],
    ['Качалка', 50],
)

for i, (item, cost) in enumerate(expenses, start=1):
    worksheet.write(f'A{i}', item)
    worksheet.write(f'B{i}', cost)

# колонкой ниже добавить подсчет суммы
worksheet.write('A5', 'Итого:')
worksheet.write('B5', '=SUM(B1:B4)')

# сохраняем и закрываем
workbook.close()

Я пользуюсь программой Numbers на macOS, в MS Office будет более привычный вид. Вот что получилось у меня:

Таблица с формулой итого

Формат

Таблица получилась немного скучновата и невыразительна. Давайте добавим форматы ячейкам, а именно ячейки столбца B сделаем в формате денег, а графу «Итого:» и заголовки – жирными. Формат создается как отдельная переменная, и его передают третьим аргументом после аргумента-содержимого ячейки.

# формат для денег
money = workbook.add_format({'num_format': '#,##0"₽"'})
# формат жирности шрифта
bold = workbook.add_format({'bold': True})

worksheet.write('A1', 'Наименование', bold)
worksheet.write('B1', 'Потрачено', bold)

for i, (item, cost) in enumerate(expenses, start=2):
    worksheet.write(f'A{i}', item)
    worksheet.write(f'B{i}', cost, money)

# колонкой ниже добавить подсчет суммы
worksheet.write('A6', 'Итого:', bold)
worksheet.write('B6', '=SUM(B2:B5)', money)

Результат:

Таблица с формтами

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

Вот, что там не написано, а хотелось бы улучшить – задать ширину столбца (колонки), чтобы влезал весь текст. Делается это так:

# для каждой колонки отдельно (первый и второй аргументы совпадают)
worksheet.set_column(0, 0, 15)
worksheet.set_column(1, 1, 20)

# или

# задать колонкам в диапазоне от 0 до 1 каждой – ширину 15
worksheet.set_column(0, 1, 15)

# или по названиям:
worksheet.set_column('A:B', 15)
worksheet.set_column('C:C', 20)

В каких единицах измеряется ширина? Черт его знает, это не сказано в документации, может, в сантидюймах? Подбирайте на глазок. Мой итог:

Увеличена ширина колонки

Графики

Они есть! Давайте построим график.

Шаг 1: зададим данные. Можно писать массив прямо в колонку, а не по каждой ячейке отдельно:

data = [
    [1, 2, 3, 4, 5],
    [2, 4, 6, 8, 10],
    [3, 6, 9, 12, 15],
]

# можно писать сразу колонками!
worksheet.write_column('A1', data[0])
worksheet.write_column('B1', data[1])
worksheet.write_column('C1', data[2])

Шаг 2. Создадим график, задав его тип – в данном случае: диаграмма-столбики. Потом зададим серии данных.

chart = workbook.add_chart({'type': 'column'})

# добавим три последовательности данных
chart.add_series({'values': '=Sheet1!$A$1:$A$5'})
chart.add_series({'values': '=Sheet1!$B$1:$B$5'})
chart.add_series({'values': '=Sheet1!$C$1:$C$5'})

Так, стоп! Что значит эта страшная строка? Она говорит, что нужно взять ячейки с листа «Sheet1» и так далее. А можно попроще? Да – задать данные через числовые координаты списком. А еще за одно в цикл завернем:

worksheet.name = 'Первый лист'
for col, series in enumerate(data):
    chart.add_series({
        # имя листа, строка начала, колонка начала, строка конца, колонка конца
        'values': [worksheet.name, 0, col, 4, col],
        'name': f'Серия {col + 1}'
    })

Шаг 3: вставить наш график в нужную ячейку:

# и вставим его в ячейку A7
worksheet.insert_chart('A7', chart)

Вот, как это выглядит:

Нарисовали график

Вообще, библиотека очень богатая. Доступно множество форматов и видов графиков и диаграмм. А еще можно объединять и разделять ячейки и даже включать макросы и скрипты!

Специально для канала @pyway. Подписывайтесь на мой канал в Телеграм @pyway 👈 

Задача на ключи словаря

Имеется такой код, где мы делаем 5 записей в словарь:

d = {}

d[float('nan')] = 1
d[float('nan')] = 2
d[1.0] = 'float'
d[1] = 'int'
d[True] = 'bool'

print(len(d))

Давайте решим ее. Для ключа словаря нам важны две вещи:

  • Хэш hash(key) – ключи с разными хэшами дадут нам разные записи в словаре.
  • Равенство ключей – если хэши равны, то проверяется равенство ключей (==), и если и они равны, то считается, что ключ тот же самый – это будет одна и та же запись в словаре.

float(‘nan’)

float('nan') – создает нам новый объект типа float со значением NaN (not a number – не число). Это специально значение. Оно получается, если результат операции не определен. Например, вычитание бесконечности из бесконечности не даст нам конкретного определенного результата, потому что бесконечность – это не число:

>>> print(float('Inf') - float('Inf'))
nan

В соответствии с IEEE 754, такое состояние задаётся через установку показателя степени в зарезервированное значение 11…11, а мантиссы — во что угодно, кроме 0 (зарезервированное значение для машинной бесконечности).

У NaN есть замечательно свойство, что он не равен никакому другому float, даже самому себе или другому NaN.

>>> x = float('nan')
>>> x == x
False
>>> hash(x)
0

Но hash от NaN всегда равен 0. Таким образом, словарь видит, что мы кладем в него ключи с одинаковым хэшем, но не равные между собой. Вывод: мы можем создать сколько угодно ключей с NaN, на вид они одинаковые, даже побитово могут совпадать, но так как каждый NaN не равен другому NaN по определению, то и dict все их считает разными!

>>> d = {}
>>> d[float('nan')] = 1
>>> d[float('nan')] = 2
>>> d
{nan: 1, nan: 2}

>>> d[float('nan')]
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
KeyError: nan

1, 1.0 и True

Пользуемся той же логикой: сначала проверяем хэши, потом, если они равны, то на равенство:

>>> hash(1), hash(1.0), hash(True)
(1, 1, 1)
>>> 1 == 1.0 == True
True

Все они равны между собой! Поэтому в словаре все эти три ключа будут отвечать ровно одной записи! Посмотрите:

>>> d = {}

>>> d[1.0] = 'float'
>>> d[1] = 'int'
>>> d[True] = 'bool'

>>> d
{1.0: 'bool'}
>>> len(d)
1

>>> d[1]
'bool'
>>> d[1.0]
'bool'
>>> d[True]
'bool'

Так как первая запись была с 1.0, то и ключ останется типа float, а значение уже будет перезаписано будущими операторами присваивания.

Ответ: 3

У нас в словаре две записи от разных float('nan') и только одна запись от трех присваиваний 1.0, 1 и True. Итого ответ – 3 (три) записи будет в словаре!

Пусть вас не путает, что в условии задачи было 5 операторов.

Специально для канала @pyway. Подписывайтесь на мой канал в Телеграм @pyway 👈 

Пакуем байты на Python: struct

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

Встроенный модуль struct как раз создан для этих целей. В низкоуровневом деле важны детали, а именно размер каждого элемента данных, их порядок в структуре, а также порядок байт для многобайтовых типов данных. Для определения этих деталей модуль struct вводит форматные строки (не путать с str.format, там другой формат).

Начнем с простого примера:

>>> import struct
>>> struct.pack("hhl", 1, 2, 3)
b'\x01\x00\x02\x00\x00\x00\x00\x00\x03\x00\x00\x00\x00\x00\x00\x00'

Здесь происходит вот что. Мы берем три числа: 1, 2, 3 и пакуем их в байты, таким образом, что первое и второе числа трактуются как тип short int (4 байта на моей машине), а последнее, как long int (8 байт на моей машине). Это типы не из Python, а из языка Си. Ознакомьтесь с типами языка Си, если хотите понимать, что они из себя представляют и какой размер в байтах имеют.

Обратная распаковка байт в кортеж значений по заданному формату:

>>> struct.unpack("hhl", b'\x01\x00\x02\x00\x00\x00\x00\x00\x03\x00\x00\x00\x00\x00\x00\x00')
(1, 2, 3)

Форматы запаковки и распаковки должны совпадать, иначе данные будут неправильно интерпретированы или испорчены, или же вообще возникнет ошибка из-за того, что размер данных не подходит под ожидаемый формат (struct.error):

>>> struct.unpack("hhl", b'\x01\x02\x03')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
struct.error: unpack requires a buffer of 16 bytes

Обратите внимание, что я выше писал, что размер элемента «h» – 4 байта именно на моей машине. Может статься так, что на машине с другим процессором, архитектурой или просто с другой версией ОС размер типа будет другой. Для 32 битных систем, это обычно будет 2 байта.

Но, что если данных передаются по сети или через носители информации между системами с разной или неизвестной заранее архитектурой? Конечно, у struct есть средства на такие случаи. Первый символ форматной строки обозначит порядок байт. Обратите внимание на таблицу:

СимволПорядок байтРазмеры типовВыравнивание
@нативныйнативныйнативное
=нативныйстандартныенет
<little-endianстандартныенет
>big-endianстандартныенет
!сетевой
(= big-endian)
стандартныенет

Нативный – значит родной для конкретно вашей машины и системы. По умолчанию порядок байт и размер типов данных как раз нативный (символ @).

Стандартный размер – размер, который фиксирован стандартом и не зависит от текущей платформы. Например, char всегда 1 байт, а int – 4 байта. Если мы планируем распространять запакованные байты, мы должны гарантировать, что размер типов будет всегда стандартный. Для этого подходит любой из символов «=«, «<«, «>«, «!» в начале форматной строки.

Little-endian и big-endian

Little-endian и big-endian – это два основных порядка байт. Представим, что у нас есть короткое целое (short int), и оно занимает два (2) байта. Какой из байтов должен идти сначала, а какой в конце?

В big-endian порядок от старшего байта к младшему. В little-endian порядок от младшего байта к старшему. Как узнать на Python какой порядок байт в системе:

>>> import sys
>>> sys.byteorder
'little'

Давайте наглядно посмотрим как пакуются байты при разных порядках. Для числа 258 в форме short младший байт будет = 2, а старший = 1:

258 = 2*20 + 1*28

>>> struct.pack("<h", 258)  # little-endian
b'\x02\x01'
>>> struct.pack(">h", 258)  # big-endian
b'\x01\x02'

Как видите порядок байт противоположный для разных случаев.

В сетевых протоколах принято использовать big-endian (символ «!» – псевдоним к «>«), а на большинстве современных настольных систем используется little-endian.

Таблица типов данных

Теперь ознакомимся с таблицей типов данных, которая дает соответствие символу форматной строки (код преобразования) с Си-типом данных, Python-типом данных и стандартный размером. Еще раз: стандартный размер будет только, если задан первый символ как «<«, «>«, «!» или «=«. Для «@» или по умолчанию – размер данных определяется текущей системой (платформо-зависимо).

СимволТип в языке СиPython типСтанд. размер
xбайт набивкинет значения
ccharbytes длины 11
bsigned charinteger1
Bunsigned charinteger1
?_Boolbool1
hshortinteger2
Hunsigned shortinteger2
iintinteger4
Iunsigned intinteger4
llonginteger4
Lunsigned longinteger4
qlong longinteger8
Qunsigned long longinteger8
nssize_tintegerзависит
Nsize_tintegerзависит
e«половинный float«float2
ffloatfloat4
ddoublefloat8
schar[]bytesуказывается явно
pchar[] — строка из Паскаляbytesуказывается явно

Коды «e«, «f«, «d» используют бинарный формат IEEE-754.

Код «x» это просто байт набивки. Он не попадает в распакованные данные, а нужен, чтобы выравнивать данные. «x» при запаковке забиваются пустыми байтами. Пример: «пусто-число-пусто-пусто-число-пусто»:

>>> struct.pack(">xBxxBx", 255, 128)
b'\x00\xff\x00\x00\x80\x00'

>>> struct.unpack('>xBxxBx', b'\x00\xff\x00\x00\x80\x00')
(255, 128)

О форматной строке

Если в форматной строке перед символом кода – число, то значит этот символ повторяется столько раз, сколько указывает число. Два кусочка кода аналогичны:

>>> struct.pack(">3h", 1, 2, 3)
b'\x00\x01\x00\x02\x00\x03'

>>> struct.pack(">hhh", 1, 2, 3)
b'\x00\x01\x00\x02\x00\x03'

Для строк (коды «s» и «p«) надо указывать число байт – длину строки, иначе будет считаться 1 байт:

>>> struct.pack("ss", b"abc", b"XYZW")  # не указал длину - потерял байты
b'aX'

>>> struct.pack("3s4s", b"abc", b"XYZW")
b'abcXYZW'

10s – одна 10-символьная строка, а 10c – 10 отдельных символов:

>>> struct.unpack('10c', b'abracadabr')
(b'a', b'b', b'r', b'a', b'c', b'a', b'd', b'a', b'b', b'r')

>>> struct.unpack('10s', b'abracadabr')
(b'abracadabr',)

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

>>> struct.pack('>6sh?', b'python', 65, True)
b'python\x00A\x01'

>>> struct.pack('> 6s h ?', b'python', 65, True)  # тоже, но с пробелами
b'python\x00A\x01'

>>> struct.unpack('> 6s h ?', b'python\x00A\x01')
(b'python', 65, True)

Полезности

Можно вычислить размер данных из форматной строки без фактической запаковки или распаковки данных:

>>> struct.calcsize('> 6s h ?')
9

Удобно распаковывать байты прямо в именованные кортежи:

>>> from collections import namedtuple
>>> Student = namedtuple('Student', 'name serialnum school gradelevel')
>>> record = b'raymond   \x32\x12\x08\x01\x08'
>>> Student._make(struct.unpack('<10sHHb', record))
Student(name=b'raymond   ', serialnum=4658, school=264, gradelevel=8)

Запаковка в буффер со смещением struct.pack_into(formatbufferoffsetv1v2...):

>>> buffer = bytearray(40)
>>> struct.pack_into('h l', buffer, 10, 3432, 340840)
>>> buffer
bytearray(b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00h\r\x00\x00\x00\x00\x00\x00h3\x05\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00')
>>>

Распаковка из буффера со смещением:

>>> x, y = struct.unpack_from('h l', buffer, 10)
>>> x, y
(3432, 340840)

Распаковка нескольких однотипных структур:

>>> chunks = struct.pack('hh', 10, 20) * 5  
>>> chunks  # 5 одинаковых штук
b'\n\x00\x14\x00\n\x00\x14\x00\n\x00\x14\x00\n\x00\x14\x00\n\x00\x14\x00'

>>> [(x, y) for x, y in struct.iter_unpack('hh', chunks)]
[(10, 20), (10, 20), (10, 20), (10, 20), (10, 20)]

Специально для канала @pyway. Подписывайтесь на мой канал в Телеграм @pyway 👈 

NumPy-бродкастинг

Эта тема не очень освещена на русском языке, но является весьма важной по паре причин: бродкастинг упрощает жизнь, и порой он же ее усложняет. Давайте разберемся что это, и как оно работает?

Бродкастинг (broadcasting) – автоматическое расширение размерности (ndim) и размеров (shape) массивов, при совершении операций (сложение, умножение и подобные) над массивами с разными размерами или размерностями, при условии, что они совместимы с правилами бродкастинга.

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

>>> y = np.array([2] * 3)
>>> y
array([2, 2, 2])
>>> x * y
array([2, 4, 6])

>>> x = np.array([1, 2, 3])
>>> x * 2
array([2, 4, 6])

NumPy не заставляет нас вручную превращать скаляр (2) в вектор [2, 2, 2], он сам добавляет размерность и клонирует содержимое нужно число раз, а потом уже производит поэлементное умножение.

Бродкастинг скалара в вектор

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

Бродкастинг работает и в более сложных ситуациях по четким правилам.

Правила бродкастинга

Буду сразу объяснять на примере. Есть два массива, которые мы желаем сложить:

>>> a = np.ones((8, 1, 6, 1))
>>> b = np.ones((7, 1, 5))
>>> a.shape
(8, 1, 6, 1)
>>> b.shape
(7, 1, 5)

# (a + b) = ?

Сначала размеры (shape) массивов выстраивается друг над другом, выравнивая по правому краю. Напомню, что справа у нас самая «глубокая» размерность.

A         (4d массив):  8 x 1 x 6 x 1
B         (3d массив):      7 x 1 x 5

Затем NumPy идет справа налево, поэлементно сравнивая каждый размер операндов. Два размера считаются совместимыми, если они равны или один из них равен единице (1). Если два размера несовместимы, бродкастинг не пройдет, возникнет ошибка.
ValueError: operands could not be broadcast together with shapes

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

A         (4d массив):  8 x 1 x 6 x 1

B         (3d массив):      7 x 1 x 5
B'        (4d массив):  1 x 7 x 1 x 5
B' = [ B ] 

Мы видим, что в примере два массива полностью совместимы для бродкастинга – (8 над 1, 1 над 7, 6 над 1, 1 над 5): в каждом из столбиков есть единичка.

Теперь происходит самое интересное – там, где размеры это единицы происходит «копирование» каждого из таких измерений столько раз, чтобы размеры по этому измерению стали равны.

A         (4d массив):  8 x 1 x 6 x 1
B         (3d массив):      7 x 1 x 5
Результат (4d массив):  8 x 7 x 6 x 5

Т. е. у A на глубоком уровне по одному числу, а у B – по 5 штук. Примерно так:

A = [ [ [ [123], ... ] ], ... ]
B = [ [ [456, 456, 456, 456, 456] ], ... ]

A' = [ [ [ [123, 123, 123, 123, 123], ... ] ], ... ] 

Значит внутренний подмассив [123] у A тоже раскопируется в 5 значений [123, 123, 123, 123] и, таким образом, станет совместим с внутренним подмассивом B, где уже было 5 чисел.

Как только все размерности выровнены путем «копирования», то можно делать любую операцию поэлементно. Форма результата будет равна форме операндов. В итоге:

>>> (a + b).shape
(8, 7, 6, 5)

Еще примеры

Примеры привожу по следам документации. Такой код:

import numpy as np

a = np.array([[0, 0, 0],
              [10, 10, 10],
              [20, 20, 20],
              [30, 30, 30]])
b = np.array([1, 2, 3])

print(a + b)
# [[ 1  2  3]
#  [11 12 13]
#  [21 22 23]
#  [31 32 33]]

Работает по следующей схеме:

Сложение матрицы и вектора

Еще больше примеров, как получается финальный размер после операции:

Картинка  (3d массив)	256 x	256 x	3
Масштаб   (1d массив)	 	 	3
Результат (3d массив)	256 x	256 x	3

A      (2d array):  5 x 4
B      (1d array):      1
Result (2d array):  5 x 4

A      (2d array):  5 x 4
B      (1d array):      4
Result (2d array):  5 x 4

A      (3d array):  15 x 3 x 5
B      (3d array):  15 x 1 x 5
Result (3d array):  15 x 3 x 5

A      (3d array):  15 x 3 x 5
B      (2d array):       3 x 5
Result (3d array):  15 x 3 x 5

A      (3d array):  15 x 3 x 5
B      (2d array):       3 x 1
Result (3d array):  15 x 3 x 5

Примеры несовместимости

А вот примеры несовместимости:

A      (1d array):  3
B      (1d array):  4 # не совпадают 3 и 4 (и ни одна из них не 1)

A      (2d array):      2 x 1
B      (3d array):  8 x 4 x 3 # второй столбик справа не совпадает (2 и 4) 

Такой код на практике даст ошибку:

import numpy as np

a = np.array([[0, 0, 0],
              [10, 10, 10],
              [20, 20, 20],
              [30, 30, 30]])
b = np.array([0, 1, 2, 3])

print(a + b)
# ValueError: operands could not be broadcast together with shapes (4,3) (4,)

Потому что тут бродкастинг не работает, так как нарушены его правила:

Тут бродкастинг не работает

Опасность бродкастинга

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

Я пока не нашел опции запретить бродкастинг в NumPy, а ответы со Stackoverflow вроде [1], [2] оказались НЕРАБОЧИМИ. Как всегда, будьте осторожны!

Специально для канала @pyway. Подписывайтесь на мой канал в Телеграм @pyway 👈