Концепция mapping в Solidity аналогична HashMap в Java или dict в Python.

Нет ничего лучше, чем аналогия с реальным миром, чтобы понять, что такое mapping в Solidity и как он себя ведет. Следующий пример был взят из поста на Reddit:

Хеш-таблица(= mapping) похожа на гардероб. Вы сдаете свою куртку и получаете номерок. Всякий раз, когда вы возвращаете этот номерок, вы немедленно получаете свою куртку. В гардеробе может быть много курток, но вы все равно получите свою куртку обратно. В гардеробе происходит много волшебства, но вам все равно, потому что главное это то, что вы немедленно получите свою куртку обратно.

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

Для чего используется mapping?

Использование mapping полезно для создания связей. Например, это удобно, если вы хотите связать адрес Ethereum с определенным балансом. Это пример стандартного контракта ERC20. Смарт-контракт отслеживает, сколько токенов принадлежит пользователю и использует для этого mapping:

contract ERC20 is Context, IERC20 {    
    using SafeMath for uint256;    
    using Address for address;   
    
    mapping (address => uint256) private _balances;
    
    ...
}

Другим примером, где можно использовать mapping, может быть управление и отслеживание пользователей/адресов, которым разрешено отправлять эфир в контракт.

mapping(address => bool) allowedToSend;

Вот еще один пример того, как можно использовать mapping в Solidity. Приведенный ниже пример кода позволит связать уровень пользователя(userLevel) с адресом Ethereum в простой игре, написанной на Solidity:

mapping(address => uint) public userLevel;

Как mapping хранит значения?

mapping хранит значения иначе, чем другие типы переменных.

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

В Solidity адрес значения mapping получается благодаря хэшированию его ключей. Хэш размером 32 байта представляет собой шестнадцатеричное значение, которое можно преобразовать в десятичное число. Это число представляет собой номер слота, в котором хранится значение для определенного ключа.

Давайте рассмотрим базовый пример с фрагментом кода ниже:

contract StorageTest {
    uint256 a;     // slot 0
    uint256[2] b;  // slots 1-2
 
    struct Entry {
        uint256 id;
        uint256 value;
    }
    Entry c;       // slots 3-4
    Entry[] d;     // slot 5 for length, keccak256(5)+ for data
    
    mapping(uint256 => uint256) e;
    mapping(uint256 => uint256) f;
}

В приведенном выше коде:

  •  e занимает слот № 6

  •  f занимает слот № 7

Но на самом деле в этих местах ничего не хранится! Не сохраняется даже длина, и отдельные значения должны быть расположены в другом месте.

На самом деле, когда объявляется mapping, для него резервируется место, как и для любого другого типа, но фактические значения хранятся в другом месте.

Поиск местоположения значения в mapping
Поиск местоположения значения в mapping

Чтобы найти местоположение определенного значения mapping, ключ и номер слота конкатенируются вместе, а потом хэшируются с помощью keccak256.

Полученный хеш соответствует местоположению в хранилище, где хранится значение, связанное с этим ключом в mapping. А если быть еще точнее, то 32-байтовый хэш содержит шестнадцатеричные символы, которые можно преобразовать в десятичное число (которое представляет собой слот для хранения определенного ключа в mapping). Пример:

mapping(address => uint) public balances; // slot 2

Переменная balance расположена в слоте № 2. Как было показано ранее, мы конкатенируем, а потом хешируем ключ с номером слота, как в приведенной ниже визуализации:

В конечном счете получаем, что баланс, связанный с адресом 0x123456…7890, можно найти в слоте с № 11233648340…332173.

У mapping нет длины

Как вы видели, данные не хранятся в самом mapping. Для поиска значения используется хеш ключа и номер слота переменной. Из-за этого mapping не имеет длины.

Тот же принцип применяется к вложенным mapping-ам. Мы поговорим об этом позже.

Доступные ключи и значения

В таблице ниже перечислены все возможные типы переменных, которые можно использовать при объявлении mapping:

mapping (KeyType => ValueType) mappingName;

Операции над mapping

Чтение значения определенного ключа

Чтобы получить значение, связанное с конкретным ключом, можно написать подобный геттер:

function currentLevel(address userAddress)
    public 
    view 
    returns (uint) 
{
    return userLevel[userAddress];
}

Однако, для этой задачи есть решение получше. Как и для любой другой переменной в Solidity, ключевое слово public автоматически создает геттер:

mapping(address => uint) public userLevel;

Запись значения для определенного ключа

Мы можем написать сеттер для установки значения:

function setUserLevel(address _user, uint _level)
    public
{
    userLevel[_user] = _level;
}

Приведенный выше код позволяет установить "уровень игры"(userLevel) конкретному пользователю.

Поиск слота хранилища с помощью ключа

Код ниже позволяет найти, в каком слоте хранится значение, связанное с конкретным ключом:

function findMapLocation(uint256 slot, uint256 key) public pure returns (uint256) {
    return uint256(keccak256(abi.encode(key, slot)));
}

Вложенность mapping

В mapping значение может быть другим mapping-ом. Другими словами, mapping может быть вложенным.

Отличный практический пример вложенного mapping можно найти в смарт-контракте ERC20. Адрес может предоставить другим адресам право потратить определенное количество токенов:

mapping (address => mapping (address => uint256)) private _allowances;

В архитектуре реляционной базы данных это отношение называется «один ко многим»: owner может разрешить нескольким spenders тратить токены от его имени.

Геттер для вложенных mapping-ов

Вернемся к нашему предыдущему примеру с квотами из контракта ERC20. Функция allowance, определенная в строке 125, принимает два параметра: owner и spender. Получить allowance можно следующим образом:

function allowance(address owner, address spender) 
    public 
    view 
    virtual override 
    returns (uint256) 
{        
    return _allowances[owner][spender];    
}

Как представлены вложенные mapping-ы в хранилище?

Вложенный mapping использует тот же шаблон, что и любой другой тип, но делает это рекурсивно:

  • Получаем хеш для ключа последнего mapping в цепи

  • Используем этот хеш на уровне выше, пока не дойдем до корня

Mapping как параметр функции

Это сложная тема, но мы попытаемся обсудить ее.

mapping может передаваться как параметр функции, но он должен удовлетворять следующим требованиям:

  • mapping можно использовать в качестве параметра только для private и internal функций.

  • Местом хранения данных для параметра функции может быть только хранилище.

Обход значений mapping

Почему вы не можете обойти значения в работе с mapping?

Поскольку 32-байтовый хэш, полученный с помощью keccak256(key, slot), представляет собой шестнадцатеричное значение, которое можно преобразовать в десятичные, это приводит к огромному возможному диапазону. Полученное десятичное число может находиться где-то в диапазоне от 0 до 2²⁵⁶.

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

Как осуществить обход значений в работе с mapping?

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

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

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

Давайте возьмем пример, который использует uint в качестве ключа. Решение состоит в том, чтобы иметь счетчик, который будет хранить длину mapping:

contract IterableMapping {

    mapping(uint => address) someList;
    uint public totalEntries = 0;
    
    function addToList() public returns(uint) {
        someList[totalEntries] =
            address(0xABaBaBaBABabABabAbAbABAbABabababaBaBABaB);
        return ++totalEntries;
    }
}

Примечание об использование Struct вместе с mapping

Важное примечание, о котором стоит упомянуть, относится к mapping, который имеет Struct в качестве типа значения.

Если Struct содержит массив, он не будет возвращен через геттер, созданный с помощью ключевого слова public. Для этого вам нужно создать свою собственную функцию, которая будет возвращать массив.

Ограничения mapping

Работа с mapping не обходится без подводных камней. Вот некоторые из них:

  • вы не можете назначать переменную как тип mapping-а. Так, например, Remix отобразит это как TypeError:

  • Как мы говорили ранее, mapping невозможно иттерировать напрямую

  • Невозможно получить список значений или ключей, как, например, в Java. Причина все та же: все переменные уже инициализированы по умолчанию. Так что список был бы очень большим.

  • mapping не может быть передан в качестве параметров внутри public и external функций в смарт-контрактах.

  • mapping нельзя использоваться в качестве возвращаемого значения функции

Комментарии (1)


  1. shuhray
    16.03.2022 23:00

    Начало прямо Бродский

    Хеш-таблица похожа на гардероб.

    Вы сдаете свою куртку и получаете номерок.