Научитесь писать оптимизированные смарт-контракты с использованием бинарной логики

Двоичные выражения являются основой самого программирования. Каждый процесс под капотом любого языка программирования сводится к манипулированию булевыми битами информации — равными либо true, либо false. Так почему бы нам не разобраться в языке процессоров и не сделать наш код более эффективным и быстрым? Эта статья прольет свет на побитовые операции в языке Solidity и варианты их использования: как примитивные, так и сложные.

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

Двоичное преобразование

Каждый тип значения в Solidity основан на двоичных данных и может быть (визуально) преобразован в 1 и 0 с помощью внешних источников или написанных чистых функций Solidity. Таким образом, бинарная алгебра может применяться как к шестнадцатеричным байтамданным, так и к десятичным целым числам без знака или со знаком (адрес легко преобразуется в байты).

И ( & ) — союз

function and(int bar, int foo) public pure returns(int) {

      /*
            Performs bitwise AND to two integers (their binary form).

            Example:
            bar:      93 (10) = 01011101 (2)
            foo:      26 (10) = 00011010 (2)
            bar & foo:          00011000 (2) = 24 (10)
      */

        return bar & foo;
    }

Результатом конъюнкции является поразрядное умножение двух двоичных выражений.

Характеристики

  1. Соединение двух одинаковых целых чисел всегда не больше любого из них.
  2. Отрицательное целое число, соединенное с положительным, всегда будет приводить к тому, что неотрицательное число будет лежать между ними.

ИЛИ ( | ) — дизъюнкция

function or(int bar, int foo) public pure returns(int) {

        /*
            Performs bitwise OR to two integers (their binary form).

            Example:
            bar:      93 (10) = 01011101 (2)
            foo:      26 (10) = 00011010 (2)
            bar | foo:          01011111 (2) = 95 (10)
        */

        return bar | foo;
    }

Побитовое ИЛИ эквивалентно логическому ИЛИ ( || ), применяемому к каждой паре двух двоичных выражений в пределах одной цифры.

Характеристики

  1. Дизъюнкция двух одинаковых целых чисел всегда не меньше любого из них.
  2. Дизъюнкция с отрицательным целым числом всегда будет отрицательной.

НЕ ( ~ ) — отрицание

function not(int foo) public pure returns(int) {

        /*
            Performs bitwise negation.

            Example:
            foo: 93 (10) = 01011101 (2)
            ~foo:          10100010 (2) = 162 (10)
        */

        return ~foo;
    }

Унарный оператор, инвертирующий каждый бит двоичного выражения.

Характеристики

  1. Отрицание меняет знак целого числа.
  2. Соединение целого числа и его отрицания всегда false.
  3. Дизъюнкция целого числа и его отрицания всегда истина.

XOR (^) — исключающая дизъюнкция

function xor(int bar, int foo) public pure returns (int) {

        /*
            Performs bitwise XOR to two integers (their binary form).

            Example:
            bar:      93 (10) = 01011101 (2)
            foo:      26 (10) = 00011010 (2)
            bar ^ foo:          01000111 (2) = 71 (10)
        */

        return bar ^ foo;
    }

Двоичная операция, при которой результирующий бит равен 1, только если ОДНА из двух цифр дизъюнктного выражения равна 1.

Характеристики

  1. Любое целое число, подвергнутое XOR с нулем, приводит к неизменному целому числу.
  2. Любое целочисленное XOR с самим собой дает ноль.

Левый "шифт ( << )

function leftShift(int number, uint positions) public pure returns(int) {

/*
   Performs left bit shift of `number` by `position` slots.

   Examples:
   int8(98) << 2 <--> 01100010 << 2 == 10001000 <--> 10001000 == -120
   int(98) << 2 <--> 00...0011100010 << 2 == 00...001110001000 <--> 00...001110001000 == 392 == 98 * 2 ^ 2

   int8(-17) << 3 <--> 11101111 << 3 == 01111000 <--> 01111000 == 120
   int(-17) << 3 <--> 11...1111101111 << 3 == 11...1101111000 <-->  11...1110001000 == -136 == -17 * 2 ^ 3
*/

        return number << positions;
    }

Применение оператора сдвига влево к двоичному выражению 0b1001010 << k приводит к смещению каждого его бита на kслотов влево.

Характеристики

  1. Операция сдвига влево не является циклической (как вы можете ожидать), а это означает, что если биты сдвинуты на размер своего контейнера, они не повторяются в наименьших цифрах.
  2. Вместо этого свободные битовые слоты после сдвига заполняются нулями.
  3. Запрещен сдвиг на отрицательное число (поэтому в примере функция принимает параметр uint position).
  4. Может вызвать переполнение.

Сдвиг вправо ( ›› )

function rightShift(int number, uint positions) public pure returns(int) {

/*
   Performs right bit shift of `number` by `position` slots.
     
   Examples:
   int*(89) >> 2 <--> 00...001100010 >> 2 == 00...0000011000 <--> 00...0000011000 == 24 == floor(98 / 4)
   int*(-17) >> 3 <--> 11...11101111 >> 3 == 11..11111101 <--> 11..11111101 == -3
*/

        return number >> positions;
    }

Применение оператора сдвига вправо к двоичному выражению 0b1001010 >> k приводит к смещению каждого его бита на kслоты вправо.

Характеристики

  1. Свободные слоты после применения сдвига заменяются первичным битом (знаковым битом) двоичного выражения, а это означает, что запасные биты после сдвига отрицательного числа заполняются единицами, тогда как в положительных будут 0.
  2. Запрещен сдвиг на отрицательное число (поэтому в примере функция принимает параметр uint position).

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

Случаи использования

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

Даже странно

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

function isEven(uint number) public pure returns(bool) {
        return number & 1 == 0;
    } 

    function isEven(int number) public pure returns(bool) {
        if(number < 0) return number & 1 != 0;
        return number & 1 == 0;
    }

Соединение любого целого числа с 1 вернет последний бит целого числа (либо 0, либо 1), поэтому приводится bool. em> тип будет иллюстрировать это целое число четным или нечетным. Обратите внимание, что если `number` отрицательное, то приведение типа bool к его последнему биту приведет к противоположности его делимости на 2 с учетом правил дополнения до 2 со знаком.

Умножение

Сдвиг влево number << k эквивалентен number * 2 ** k .

function multiply(int number, uint k) external pure returns(int) {
      return number << k;
  }

Это следствие свойства преобразования двоичных чисел в десятичные. Например, 0b101001 = 2**0 + 2**3 + 2**5. Сдвинем его влево на 2 слота:
0b10100100 = 2**2 + 2**5 + 2**7 = 4 * (2**0 + 2**3 + 2**5). Получаем исходное утверждение, умноженное на 4, то есть 2 ** 2.

Разделение

Сдвиг вправо number >> k эквивалентен floor(number / 2**k), где floor() преобразует свой параметр в меньшее целое число.

function divide(int number, uint k) external pure returns(int) {
        return number >> k;
    }

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

Обратите внимание, что деление по умолчанию округляется до нуля, тогда как деление сдвига округляется до наименьшего.

Битовые флаги

Битовые флаги — это сложный способ составления переменных состояния и обеспечения эффективности и скорости операций с ними.

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

constructor() {
                                        // masks:
        params["male"] =  1 << 0;       // 0000000001 (2)
        params["female"] =  1 << 1;     // 0000000010 (2)
        params["single"] =  1 << 2;     // 0000000100 (2)
        params["married"] =  1 << 3;    // 0000001000 (2)
        params["chlid-free"] =  1 << 4; // 0000010000 (2)
        params["1 kid"] =  1 << 5;      // 0000100000 (2)
        params["2 kids"] =  1 << 6;     // 0001000000 (2)
        params["employed"] = 1 << 7;    // 0010000000 (2)
        params["freelance"] = 1 << 8;   // 0100000000 (2)
        params["toRecieve"] = 1 << 9;   // 1000000000 (2) 

        CEO = msg.sender;
    }

Будет сохранен список параметров в mapping (string => uint) params, где значение uint равно степени двойки. Это оставляет нам группу битовых флагов, которые мы можем комбинировать для описания получателей, также использующих mapping (address => uint) recipients. Такой подход также позволяет создавать битовые маски, которые используются в качестве требования к фонду.

function assignParams(address applicant, string[] memory qualities) external {
        require(msg.sender == CEO, "you dont have permission!");

        for(uint i; i < 0; i++) {
            recipients[applicant] ^= params[qualities[i]];
        }
    }

Позволит генеральному директору контракта Subsidizer назначать качества кандидатам с помощью XOR. Поэтому пример записи в отображении recipients будет выглядеть как 0xAbC...5f9 -> 713 (1011001001 (2)), что означает, что пользователь с адресом 0xAbC...5f9 является работающим женатым мужчиной с двумя детьми, который может запросить фонд. Эксклюзивный дизъюнктивный подход позволяет отзывать роли.

function getFunds() external {
        uint requirement = params["female"] | params["married"] | params["2 kids"] | params["toRecieve"]; 
        uint receiverRoles = recipients[msg.sender];
        require((receiverRoles != 0) && (receiverRoles & ~(requirement) == 0), "access denied");
        recipients[msg.sender] -= 2 ** 9;
        payable(msg.sender).transfer(sum);
    }

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

Затем мы должны отменить возможность запроса средств еще раз, вычитая 2**9 из ролей получателя. По законам булевой алгебры это превратит десятый бит из 1 в 0. После этого мы можем осуществить перевод средств получателю без риска повторного входа.

Заключение

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

Примеры договоров с пояснениями можно найти здесь.

Андрей Самокиш