Хранение данных: mapping, struct, массивы и события
- Зачем это нужно
- Три места, где живут данные
- mapping: словарь без длины и без списка ключей
- struct: запись из полей
- storage — ссылка, memory — копия
- Массив: push, length и нумерация с нуля
- delete оставляет дыру, а не укорачивает массив
- Вложенные словари
- Цена записи: три разных числа за один и тот же вызов
- События: журнал для внешнего мира
- Типичные ошибки
- Слова главы
- Тренажёр: набрать вслепую
- На чемпионате
- Что дальше
- Углубиться
- Экзамен по блоку «Основы Solidity»
- Куда дальше: проверенные ресурсы
Зачем это нужно
Эталонный контракт задания хранит данные о пользователях в трёх словарях и трёх массивах записей. Ровно там же живут три ошибки, которые ищут в отчётах модуля В: удаление, оставляющее «заявку от нулевого адреса»; локальная копия структуры, изменения которой исчезают без следа; и попытка держать в хранилище то, для чего есть журнал событий.
Это последняя глава блока про основы Solidity. После неё — экзамен по всему блоку.
Три места, где живут данные
У данных внутри контракта есть три возможных места, и выбрать нужно осознанно.
| Место | Сколько живёт | Цена |
|---|---|---|
storage | навсегда, в блокчейне | дорого: тысячи газа за запись |
memory | пока выполняется функция | дёшево |
calldata | байты, пришедшие с транзакцией; только чтение | дешевле memory |
Переменные состояния — те, что объявлены в контракте вне функций, — всегда в storage. Локальные строки, структуры и массивы внутри функции требуют явного указания: memory или calldata. Забыли — компилятор откажет.
Ячейка storage — это 32 байта, и каждая её запись оплачивается отдельно. Насколько именно — увидим числами.
mapping: словарь без длины и без списка ключей
mapping(address => User) userMap;
mapping(string => address) loginMap;
Главная особенность: у словаря нет длины и нет списка ключей. Спросить «сколько записей» или «перечисли все адреса» нельзя — таблица не хранит этого.
Второе следствие: запрос по ключу, которого никогда не записывали, не даёт ошибки. Возвращается «пустое» значение типа: ноль для числа, false для логического, нулевой адрес, структура из нулей. Отличить «не записано» от «записано нулём» невозможно — если это важно, добавляют отдельное поле-флаг:
struct User {
string login;
address wallet;
bool exists; // вот он, флаг: записан ли пользователь вообще
}
struct: запись из полей
Структура объявляет новый тип из нескольких полей — как строка таблицы:
struct Whitelist {
string login;
address wallet;
bool status;
}
Создаётся перечислением значений по порядку — Whitelist("anna", msg.sender, false); поле читается через точку: w.login. Структуры кладут в словари и в массивы; возвращать наружу их можно, mapping — нельзя.
storage — ссылка, memory — копия
Локальной переменной-структуре тоже нужно место, и выбор меняет смысл кода:
User storage u = userMap[addr]; // ссылка на запись в хранилище
u.publicTokens += 10; // изменение попадает в хранилище
User memory v = userMap[addr]; // копия на время вызова
v.publicTokens += 10; // изменилась копия; после return её нет
Обе строки компилируются. Обе выглядят одинаково полезными. Вторая — тихая ошибка: функция отработала, транзакция прошла, состояние не изменилось. Ни компилятор, ни сеть об этом не сообщат.
Правило: собираетесь менять — берите storage. Только читаете и возвращаете наружу — memory. Ошибку с копией не ловит ничего, кроме проверки результата после вызова.
Массив: push, length и нумерация с нуля
Whitelist[] private requests;
requests.push(Whitelist("anna", msg.sender, false)); // длина выросла
uint256 n = requests.length; // сколько записей
Whitelist storage first = requests[0]; // первый элемент
Элементы нумеруются с нуля: в массиве из трёх записей допустимы индексы 0, 1, 2. Обращение к индексу 3 даёт панику 0x32 — «выход за границу», транзакция откатывается.
delete оставляет дыру, а не укорачивает массив
В эталонном контракте отклонённая заявка удаляется строкой delete requests[_index]. Первая модель показывает, что при этом происходит:
── три заявки в обоих массивах ──
[0] «anna» кошелёк 0x7099…79C8 статус false
[1] «boris» кошелёк 0x3C44…93BC статус false
[2] «vera» кошелёк 0x90F7…b906 статус false
── убрали boris через delete requests[1] (как в эталоне) ──
длина массива: 3
[0] «anna» кошелёк 0x7099…79C8 статус false
[1] «» кошелёк 0x0000…0000 статус false
[2] «vera» кошелёк 0x90F7…b906 статус false
── убрали boris через swap-and-pop ──
длина массива: 2
[0] «anna» кошелёк 0x7099…79C8 статус false
[1] «vera» кошелёк 0x90F7…b906 статус false
── что видит цикл поиска после delete ──
boris найден: false
нулевой адрес найден: true, индекс 1 ← дыра выглядит как заявка от 0x000…0000
── газ на удаление ──
delete requests[1]: 27499
swap-and-pop: 40145
delete не удаляет элемент — он записывает в него нули. Длина не меняется, и любой цикл по массиву продолжает видеть эту позицию. Дальше начинаются странности: заявка от нулевого адреса «находится», счётчик заявок врёт, а показ списка на экране выводит пустую строку.
Второй способ — swap-and-pop: переставить последний элемент на место удаляемого и укоротить массив. Длина честная, дыр нет — но порядок элементов меняется. И он дороже: 40 145 газа против 27 499, потому что за обнулённую ячейку сеть возвращает часть газа.
Что выбрать: если порядок не важен — swap-and-pop; если важен — не удалять вовсе, а помечать записи флагом status.
Вложенные словари
Значением словаря может быть другой словарь:
mapping(address => mapping(address => uint256)) allowance;
Читается как «сколько владелец разрешил списать получателю»: allowance[owner][spender]. Ровно так устроено разрешение на списание в стандарте токена, который требуется в модуле А задания.
Из функции можно вернуть структуру или массив, но не словарь: у него нет ни длины, ни списка ключей, значит и передать его целиком нечем.
Цена записи: три разных числа за один и тот же вызов
Вторая модель вызывает одну и ту же функцию set(v) три раза подряд:
── одна ячейка storage, три записи ──
новая ячейка 0 → 5 газ 43746
перезапись 5 → 7 газ 26646
обнуление 7 → 0 газ 21834
новая ячейка дороже перезаписи на 17100
обнуление дешевле перезаписи на 4812 ← сеть вернула часть газа
── массив на 10 чисел: memory против storage ──
uint256[] memory газ 28284
uint256[] storage газ 276926
storage дороже в 9.8 раза
за один push в storage ≈ 24864 газа
── что осталось после вызовов ──
value: 0
list[0]: 1, list[9]: 10 ← storage-массив лежит навсегда
memory-массива нет: у контракта даже нет переменной, чтобы его спросить
Три вывода для отчёта по оптимизации:
Первая запись дороже всех. Превратить нулевую ячейку в ненулевую стоит на 17 100 газа больше, чем изменить уже занятую.
Обнуление возвращает часть газа. Освободили ячейку — сеть вернула 4 812. Отсюда практический приём: обнулять то, что больше не нужно, выгодно.
Промежуточные вычисления — в memory. Десять чисел в памяти обошлись в 28 284 газа, те же десять в хранилище — в 276 926. Разница почти в десять раз; каждый push в хранилище стоит около 24 864 газа.
События: журнал для внешнего мира
Контракт может писать в журнал (log) — отдельное место, которое хранится в блоках, но недоступно самому контракту:
event Deposited(address indexed who, uint256 amount, uint256 time);
function deposit() public payable {
balance[msg.sender] += msg.value;
emit Deposited(msg.sender, msg.value, block.timestamp);
}
Третья модель показывает, зачем это нужно:
── что можно узнать из storage ──
balance[anna] = 140
balance[boris] = 250
operations = 3
кто, сколько и когда пополнял — в storage НЕ записано, только итоги
── тот же контракт, читаем журнал событий ──
блок 2: 0x7099…79C8 внёс 100 (время 1800000000)
блок 3: 0x3C44…93BC внёс 250 (время 1800000060)
блок 4: 0x7099…79C8 внёс 40 (время 1800000120)
всего событий: 3
── только события anna (фильтр по indexed-полю) ──
0x7099…79C8 внёс 100
0x7099…79C8 внёс 40
── цена: событие против записи истории в storage ──
deposit c emit (первый вызов anna, новая ячейка balance): 68247
deposit c emit (повторный вызов anna): 34047
depositWithHistory (повторный вызов anna): 120833
запись в storage дороже события на 86786 газа
Итоги — в хранилище, история — в журнале. Контракт помнит, что у anna 140 и что операций было три. Кто, сколько и когда вносил — этого в хранилище физически нет, и добавлять туда историю дорого: 120 833 газа против 34 047.
Журнал читает внешний мир, не контракт. Интерфейс, скрипт, дашборд — все они подписываются на события и строят по ним ленту операций. Сам контракт свои события прочитать не может: это плата за дешевизну.
Фильтр по indexed. Пометив поле словом indexed, вы разрешаете внешнему коду спрашивать «только операции этого адреса» — без перебора всего журнала.
Типичные ошибки
Параметр-строка без места хранения. TypeError: Data location must be "memory" or "calldata" for parameter in function, but none was given. — есть в эталонном контракте.
Опечатка .lenth. Member "lenth" not found or not visible after argument-dependent lookup in Whitelist[] storage ref. — тоже из эталона.
Локальная структура без storage / memory. Компилятор потребует указать место явно.
Изменение копии вместо записи. User memory вместо User storage — тихая потеря изменений.
delete вместо настоящего удаления. Длина не меняется, в массиве появляется запись из нулей.
Обращение за границу массива. Паника 0x32, откат всей транзакции.
Попытка вернуть mapping из функции. Компилятор откажет: у словаря нет ни длины, ни перечисления.
emit забыт. TypeError: Event invocations have to be prefixed by "emit".
История операций в массиве хранилища. Компилируется и работает, но каждая операция дороже на 86 786 газа — пункт для отчёта по оптимизации.
Слова главы
storage— постоянное хранилище контракта; ячейки по 32 байта, запись платная.memory— временная память на время вызова;calldata— данные транзакции, только чтение.- Переменная состояния — объявлена в контракте вне функций, всегда в
storage. mapping— словарь «ключ → значение»; нет длины, нет списка ключей, неизвестный ключ отдаёт нули.struct— запись из полей; кладётся в словари и массивы, возвращается наружу.- Динамический массив —
push,length, индексация с нуля; за границей паника0x32. delete— обнуляет элемент, но не укорачивает массив.- swap-and-pop — перестановка последнего элемента на место удаляемого; длина честная, порядок теряется.
- Вложенный
mapping— словарь словарей:allowance[owner][spender]. event/emit— объявление и запись в журнал;indexed— поле, по которому можно фильтровать.- Журнал (log) — история для внешнего мира; контракту недоступна.
Тренажёр: набрать вслепую
Цель: скорость ≥ 100 зн/мин, точность ≥ 88%
mapping(address => User) userMap;
На чемпионате
Модуль В, 14 баллов. Правило задания одно и то же во всех годах: «Не разрешается изменять описание и определение приведённых в контракте структур данных». Значит, struct и mapping в отчёте не переписывают — пишут про то, как с ними работают. Готовые пункты из этой главы:
- Логика:
delete requests[_index]оставляет заявку от нулевого адреса; предложить пометку флагом или swap-and-pop. - Синтаксис:
requests.lenth, параметрstring _loginбезmemory. - Оптимизация: история операций в массиве вместо события — с числом: 120 833 против 34 047 газа; промежуточные вычисления в
memoryвместоstorage— почти вдесятеро дешевле. - Информационная безопасность: пароли в словаре — в блокчейне всё публично, приватность полей ничего не скрывает.
Модуль А, 37 баллов. Дашборд из требований задания — «статистика ликвидаций», «количество активных займов», «общий объём shares» — строится из двух источников: итоги читаются view-функциями из хранилища, лента событий — из журнала. Если историю писать в хранилище, каждая операция подорожает на десятки тысяч газа, а демонстрация «показать изменения всех затронутых балансов» станет медленнее.
Что дальше
Это конец первого блока. Теперь известно всё, из чего состоит простой контракт: типы, проверки, ветвления, функции и данные. Дальше — второй блок: контракт как объект. Конструктор, который выполняется один раз, перечисления ролей, наследование, интерфейсы для общения контрактов между собой, библиотеки, хеши и паттерны безопасности.
Но сначала — экзамен по блоку.
Углубиться
- Раскладка по слотам — как компилятор упаковывает несколько маленьких полей в одну 32-байтовую ячейку и почему порядок полей в структуре меняет цену.
bytesпротивstring— обе последовательности байтов, но уbytesесть длина и индексация.- Анонимные события — без подписи в журнале: дешевле, но фильтровать по имени нельзя.
pop()— укорачивает массив с конца; в паре с перестановкой это и есть swap-and-pop.- Возврат газа — за какие операции сеть возвращает часть платы и почему на это нельзя закладываться в расчётах.
Сводная проверка по всей главе: 8 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.
Экзамен по блоку «Основы Solidity»
Первый блок закончен: восемь глав, от первой строки кода до устройства хранилища. Проверка — по всему блоку сразу.
Сводная проверка по 6 главам блока: 15 вопросов, 10:00 на всё, по одному вопросу, без возврата. Пересдавать можно сколько угодно раз.
Готовность: экзамены глав сданы 0 из 6. Блок можно сдавать и так, но шанс выше после всех глав.
Куда дальше: проверенные ресурсы
- Места хранения данных — docs.soliditylang.org —
storage,memory,calldataи правила присваивания между ними. - Отображения — почему нет длины и перечисления ключей.
- Массивы —
push,pop,length,delete. - События —
emit,indexed, устройство журнала. - Цены операций: EIP-2929 — откуда берутся 20 000, 2 900 и 2 100 газа в расчётах записи.
Комментарии
Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.