Перейти к основному содержимому
Блокчейн08ГЛАВА 08Хранение данных: mapping, struct, массивы и события709длина всё ещё 3emit → журналитоги и история

Хранение данных: mapping, struct, массивы и события

Прочитано0%
Квизы0/5
Тренажёрыне пройден0/1
Уровень 1 · Новичок

Зачем это нужно​

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

Это последняя глава блока про основы 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 НЕ УКОРАЧИВАЕТ МАССИВ — ОН ОБНУЛЯЕТ ЯЧЕЙКУбыло: длина 3[0] anna[1] boris[2] veradelete requests[1]стало: длина по-прежнему 3[0] anna[1] «» · 0x0000…0000[2] veraчто видит цикл поискаboris найден: falseнулевой адрес найден: trueдыра выглядит как заявкаот адреса 0x000…0000swap-and-pop даёт длину 2, но переставляет последний элемент на место удалённогои стоит дороже: 40 145 газа против 27 499 — за обнулённую ячейку сеть возвращает часть газа
После delete длина осталась прежней, а на месте записи — нули, которые цикл читает как настоящую заявку

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

Второй способ — swap-and-pop: переставить последний элемент на место удаляемого и укоротить массив. Длина честная, дыр нет — но порядок элементов меняется. И он дороже: 40 145 газа против 27 499, потому что за обнулённую ячейку сеть возвращает часть газа.

Что выбрать: если порядок не важен — swap-and-pop; если важен — не удалять вовсе, а помечать записи флагом status.

Отвечено вопросов: 0 из 2
В массиве из трёх записей выполнили delete requests[1]. Какой станет длина?
User memory u = userMap[addr]; u.tokens += 10; Что изменится в хранилище?
Отвечено верно: 0 из 2

Вложенные словари​

Значением словаря может быть другой словарь:

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-массива нет: у контракта даже нет переменной, чтобы его спросить
ОДИН И ТОТ ЖЕ SET() — ТРИ РАЗНЫЕ ЦЕНЫновая ячейка · 0 → 543 746перезапись · 5 → 726 646обнуление · 7 → 021 834новая ячейка дороже перезаписи на 17 100 — цена превращения нуля в не-нольобнуление дешевле перезаписи на 4 812: сеть возвращает часть газа за освобождённую ячейкудесять чисел: в memory — 28 284 газа, в storage — 276 926, в 9,8 раза дороже (≈24 864 за push)
Цена записи зависит от того, что лежало в ячейке до неё: заполнить ноль дороже всего, обнулить — дешевле, чем перезаписать

Три вывода для отчёта по оптимизации:

Первая запись дороже всех. Превратить нулевую ячейку в ненулевую стоит на 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 газа
ИТОГИ — В ХРАНИЛИЩЕ, ИСТОРИЯ — В ЖУРНАЛЕstorage контрактаbalance[anna] = 140operations = 3кто, сколько и когда вносил —такой истории в нём нетжурнал событийблок 2: anna внесла 100блок 3: boris внёс 250блок 4: anna внесла 40фильтр по indexed-адресу — только annaпополнение с emit — 34 047 газа; то же пополнение с записью истории в массив — 120 833журнал дешевле хранилища на 86 786 газа за операциюно сам контракт свои события прочитать не может: журнал — для внешнего мира
Хранилище знает только итоги; историю операций отдаёт журнал — и обходится на 86 786 газа дешевле

Итоги — в хранилище, история — в журнале. Контракт помнит, что у anna 140 и что операций было три. Кто, сколько и когда вносил — этого в хранилище физически нет, и добавлять туда историю дорого: 120 833 газа против 34 047.

Журнал читает внешний мир, не контракт. Интерфейс, скрипт, дашборд — все они подписываются на события и строят по ним ленту операций. Сам контракт свои события прочитать не может: это плата за дешевизну.

Фильтр по indexed. Пометив поле словом indexed, вы разрешаете внешнему коду спрашивать «только операции этого адреса» — без перебора всего журнала.

Отвечено вопросов: 0 из 2
Дашборду нужна лента «кто и когда пополнял». Где её хранить?
Может ли контракт прочитать собственные события?
Отвечено верно: 0 из 2

Типичные ошибки​

Параметр-строка без места хранения. 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-функциями из хранилища, лента событий — из журнала. Если историю писать в хранилище, каждая операция подорожает на десятки тысяч газа, а демонстрация «показать изменения всех затронутых балансов» станет медленнее.

Отвечено вопросов: 0 из 2
Что писать в отчёте по логике про delete requests[_index]?
Почему промежуточные вычисления делают в memory?
Отвечено верно: 0 из 2

Что дальше​

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

Но сначала — экзамен по блоку.

Отвечено вопросов: 0 из 1
Запрос mapping по ключу, которого никогда не записывали. Что вернётся?
Отвечено верно: 0 из 1

Углубиться​

  • Раскладка по слотам — как компилятор упаковывает несколько маленьких полей в одну 32-байтовую ячейку и почему порядок полей в структуре меняет цену.
  • bytes против string — обе последовательности байтов, но у bytes есть длина и индексация.
  • Анонимные события — без подписи в журнале: дешевле, но фильтровать по имени нельзя.
  • pop() — укорачивает массив с конца; в паре с перестановкой это и есть swap-and-pop.
  • Возврат газа — за какие операции сеть возвращает часть платы и почему на это нельзя закладываться в расчётах.

Сводная проверка по всей главе: 8 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.

Экзамен по блоку «Основы Solidity»​

Первый блок закончен: восемь глав, от первой строки кода до устройства хранилища. Проверка — по всему блоку сразу.

Экзамен по блоку: Основы Solidity

Сводная проверка по 6 главам блока: 15 вопросов, 10:00 на всё, по одному вопросу, без возврата. Пересдавать можно сколько угодно раз.

Готовность: экзамены глав сданы 0 из 6. Блок можно сдавать и так, но шанс выше после всех глав.

Куда дальше: проверенные ресурсы​

Видео по теме

ВидеоSolidity и смарт-контракты Ethereum, урок #3 | Типы данных string, address, mappIlya Krukowski
ВидеоSolidity и смарт-контракты Ethereum, урок #4 | Типы данных array, bytes, enum, sIlya Krukowski
ВидеоКак УБИТЬ 50% газа? Практика storage, memory и calldataGuide DAO
ВидеоSolidity и смарт-контракты Ethereum, урок #7 | События, модификаторы, require/reIlya Krukowski
ВидеоStorage, Memory and Calldata | Solidity 0.8Smart Contract Programmer

Комментарии

Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.