Массивы, циклы и проблема газа
Массивы в Solidity есть, но цикл по неограниченному массиву — мина: он может упереться в лимит газа и навсегда заблокировать функцию.
«Раздай награды всем участникам в одном цикле» — звучит логично, а на деле это распространённый способ заморозить контракт. Газ не бесконечен.
Есть два вида массивов: фиксированные (uint256[5]) и динамические (uint256[]). У динамических есть push (добавить) и pop (убрать с конца), у обоих — length и индексация. Массивы можно держать в storage (постоянно) или в memory (временно, фиксированной длины).
Опасность цикла по массиву
Каждая итерация цикла тратит газ, а у блока есть жёсткий лимит. Если массив может расти без границ (например, список всех вкладчиков), однажды цикл по нему потребует больше газа, чем влезает в блок — и функция перестанет исполняться вообще. Это классический unbounded loop / DoS by gas.
ЦИКЛ ПО РАСТУЩЕМУ МАССИВУ
========================
users = [u1, u2, u3, ... uN]
for u in users: reward(u) <- газ растёт линейно с N
|
при больших N -----------------+--> OUT OF GAS -> функция мертва
РЕШЕНИЕ: pull-модель (каждый забирает сам) ИЛИ пагинация по диапазону
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract Rewards {
mapping(address => uint256) public owed; // pull-модель
// ПЛОХО: цикл по неограниченному массиву (для иллюстрации)
// function payAll(address[] memory users) external { ... for ... }
// ХОРОШО: каждый забирает свою награду сам, без цикла
function claim() external {
uint256 amount = owed[msg.sender];
require(amount > 0, "nothing");
owed[msg.sender] = 0; // эффект до взаимодействия
payable(msg.sender).transfer(amount);
}
}
Как работает под капотом (EVM/газ)
Лимит газа блока сейчас порядка 30 млн. Каждая итерация, особенно с записью в storage, легко стоит десятки тысяч газа. Несложно посчитать предел: если одна итерация — 25000 газа, то больше ~1000 элементов вы в одну транзакцию не обработаете. Поэтому массовые операции в блокчейне переводят на pull-модель: контракт записывает, сколько кому причитается, а каждый пользователь сам вызывает claim() — газ платит он, и за себя одного.
# Та же логика на Python: оценка газа цикла vs pull-модель
GAS_PER_ITER = 25000
BLOCK_GAS_LIMIT = 30_000_000
def can_pay_all(n_users):
need = n_users * GAS_PER_ITER
return need, need <= BLOCK_GAS_LIMIT
for n in (100, 1000, 5000):
need, ok = can_pay_all(n)
print(f"{n} юзеров -> {need} газа, влезает в блок: {ok}")
# pull-модель: каждый claim — константа, не зависит от числа юзеров
print("claim() одного юзера:", GAS_PER_ITER, "газа, независимо от N")
«Та же логика на Python ▶». При росте числа пользователей цикл перестаёт влезать в блок, а pull-модель остаётся константной — поэтому её и предпочитают.
Частые ошибки
- Раздавать средства/награды циклом по массиву, который растёт от действий пользователей — рано или поздно DoS по газу.
- Удалять элемент из середины массива через сдвиг — это
O(n)записей в storage, очень дорого. - Хранить большие массивы в storage, когда хватило бы маппинга или событий.
Best practices
- Предпочитайте pull-модель push-модели: пусть пользователь сам забирает причитающееся.
- Если цикл неизбежен — ограничивайте его диапазоном (пагинация) и контролируйте максимальную длину.
- Для удаления из массива без порядка используйте «swap-and-pop»: переставить последний элемент на место удаляемого и сделать
pop— этоO(1).
storage-массивы против memory-массивов
Ещё одно различие, на котором спотыкаются новички: массив в storage и массив в memory ведут себя по-разному. Storage-массив динамический, у него есть push/pop, он сохраняется между транзакциями. Memory-массив создаётся на время вызова и имеет фиксированную длину, заданную при создании: uint256[] memory tmp = new uint256[](n) — добавить элемент в него уже нельзя, только перезаписать по индексу. Это сделано из-за стоимости: динамический рост в памяти потребовал бы перевыделения, а EVM-память растёт только линейно и за неё тоже платят газ (квадратично при сильном расширении). Поэтому типичный приём — собрать результат во временный memory-массив фиксированного размера и вернуть его наружу, не трогая дорогой storage. А ещё помните про calldata: для внешних функций массивы-аргументы лучше принимать как calldata — это самый дешёвый, доступный только для чтения вид данных.
Итоги
Массивы удобны, но циклы по неограниченным массивам в блокчейне опасны из-за лимита газа. Pull-модель и пагинация спасают от DoS. Мы прошли все базовые структуры; дальше — токены ERC-20 и ERC-721.