Типы данных: чем описать кампанию
Solidity - язык строгий: у каждой переменной заранее известен тип, и никаких «ну, там где-то число». Разберём базовые типы и тут же опишем ими цель, дедлайн и создателя нашей кампании.
- целые числа,
bool,address, байты и строки - и когда что брать; - чем
constantиimmutableотличаются от обычных переменных; - что такое storage, memory и calldata и почему это не придирка, а вопрос газа.
2.1Целые числа
Основной рабочий тип в контрактах - целые без знака: uint8, uint16, …, uint256 (число в названии - это биты). Запись uint - псевдоним для uint256. Есть и знаковые: int8 … int256.
Дробных чисел в Solidity нет вовсе. Совсем. Даже 0,5 не запишешь.
| Тип | Диапазон | Когда брать |
|---|---|---|
uint256 |
0 … 2²⁵⁶−1 | По умолчанию для сумм, счётчиков, времени. EVM работает со 256-битными словами, поэтому это самый «родной» размер. |
uint8 |
0 … 255 | Маленькие величины, перечисления. В одиночку газ не экономит - выгода только при упаковке (продвинутый курс). |
int256 |
−2²⁵⁵ … 2²⁵⁵−1 | Когда нужны отрицательные значения. В финансах встречается реже, чем кажется. |
Как же тогда считать эфир, если денег без копеек не бывает? В самых мелких единицах. 1 эфир = 10¹⁸ вей (wei), и внутри контракта все суммы - это целое число вей. Те самые «3.5 ETH» существуют только в интерфейсе кошелька, для людей. А чтобы не пересчитывать нули вручную, есть литералы с единицами:
uint256 goal = 10 ether; // то же самое, что 10 * 10**18 вей uint256 tip = 100 gwei; // 100 * 10**9 вей
Вспомните механический счётчик пробега: докрутили до 999 999 - и снова нули. С uint256 то же самое: прибавьте 1 к максимуму, и число «провернётся» в 0. Это переполнение. До версии 0.8 оно происходило молча и стоило немало взломанных контрактов. С версии 0.8 компилятор по умолчанию проверяет каждую арифметическую операцию и откатывает транзакцию при переполнении. Мы пишем под 0.8+, так что защита включена сама собой, но помнить о ней всё равно нужно (подробно - в курсе по аудиту).
2.2Логический тип и адреса
bool - это true или false; пригодится для флагов вроде «средства уже выведены».
address - 20-байтовый адрес аккаунта, пользователя или контракта. Для нас это, пожалуй, главный тип: адресами мы будем помечать и создателя кампании, и каждого вкладчика. Адреса бывают двух сортов:
| Тип | Смысл |
|---|---|
address |
Просто адрес. Можно прочитать его баланс через addr.balance. |
address payable |
Адрес, которому разрешено отправлять эфир методами .transfer() и .send(). Переводить деньги на обычный address этими методами нельзя - компилятор не пропустит. |
address owner; // кому-то принадлежит кампания address payable recipient; // сюда можно будет перечислить сбор // нулевой адрес - «ничей», часто признак ошибки address zero = address(0);
address(0) - двадцать нулевых байт, «адрес никого». Неинициализированная переменная-адрес равна именно ему. Перевод токенов на нулевой адрес обычно означает их «сжигание», а деньги, пришедшие «от нулевого адреса», - почти всегда признак бага.
Проверка «а не ноль ли это?» - классический защитный приём.
2.3Байты и строки
Для произвольных данных есть байтовые типы. Различают фиксированные и динамические:
| Тип | Что это |
|---|---|
bytes32 |
Ровно 32 байта фиксированной длины. Дёшев и удобен для хешей и коротких идентификаторов. |
bytes |
Массив байтов переменной длины. Для произвольных двоичных данных. |
string |
Текст в кодировке UTF-8. Под капотом - те же динамические байты. |
В EVM нет операций над строками: нельзя узнать длину в символах, сравнить две строки напрямую или дёшево их склеить. Строки там - гости, которых пустили без особой радости. Сравнивают их обычно по хешу: keccak256(bytes(a)) == keccak256(bytes(b)). Поэтому текст в контрактах держат по минимуму, вроде короткого названия кампании, а всё остальное выносят за пределы блокчейна.
2.4constant и immutable: значения, которые не меняются
Некоторые величины задаются один раз и больше не трогаются. Для них есть два ключевых слова, и оба заметно экономят газ, потому что значение не занимает дорогое хранилище:
| Модификатор | Когда задаётся | Пример |
|---|---|---|
constant |
Прямо в коде, на этапе компиляции. Известно всем ещё до развёртывания. | Минимальный вклад, зашитый навсегда. |
immutable |
Один раз в конструкторе при развёртывании. Дальше не меняется. | Адрес создателя, цель, дедлайн конкретной кампании. |
- А зачем вообще помечать то, что я и так не собираюсь менять?
- Ради денег, причём денег ваших пользователей. Обычная переменная занимает слот в хранилище, и каждое её чтение стоит газа. constant и immutable вшиваются прямо в код контракта, и чтение обходится почти даром. К тому же компилятор не даст их случайно перезаписать.
uint256 public constant MIN_CONTRIBUTION = 0.01 ether; // задано навсегда address public immutable owner; // задаётся при деплое uint256 public immutable goal; uint256 public immutable deadline;
Вот и первые настоящие поля нашего контракта. owner, goal и deadline у каждой кампании свои, но после запуска не меняются - значит, это immutable. А минимальный вклад одинаков для всех кампаний и зашит в код - это constant. Заполнять immutable-поля будем в конструкторе из модуля 3.
2.5Структуры и перечисления
Структура (struct) объединяет несколько значений в один именованный тип - удобно, чтобы описать «запись о вкладчике» или «состояние кампании» одним блоком:
struct Contributor { uint256 amount; // сколько внёс uint256 lastDonation; // время последнего вклада }
Перечисление (enum) задаёт ограниченный набор именованных состояний. Под капотом это маленькое целое, но в коде читается куда понятнее, чем «статус = 2»:
enum State { Active, Succeeded, Failed } State public state; // по умолчанию - первое значение, Active
Перечисление идеально описывает жизненный цикл кампании: пока идёт сбор - Active, цель взята - Succeeded, время вышло без цели - Failed. К этому автомату состояний мы вернёмся в модуле 6.
2.6Где живут данные: storage, memory, calldata
Вот где новички спотыкаются чаще всего: у сложных типов нужно указывать «место хранения». Компилятор тут не придирается - от этой пометки зависит, сохранятся ли данные и сколько газа сгорит.
| Область | Живёт | Смысл |
|---|---|---|
storage |
В блокчейне, постоянно | Переменные состояния контракта. Запись - самая дорогая операция. Переживает транзакции. |
memory |
В оперативной памяти вызова | Временные данные на время одной функции. Дешевле storage, стираются после выхода из функции. |
calldata |
В данных транзакции, только чтение | Аргументы, пришедшие извне. Нельзя менять, зато самый дешёвый способ их читать. |
// аргумент только читаем - берём calldata, это дешевле всего function setName(string calldata newName) external { ... } // временная копия для вычислений внутри функции - memory function build() internal { Contributor memory c = Contributor(0, block.timestamp); ... }
Если взять из storage структуру в переменную с пометкой memory, вы получите копию, и изменения в блокчейн не попадут. Пометка storage даёт ссылку - правки сохранятся. Перепутать эти два случая - классика жанра: код компилируется, функция отрабатывает без ошибок, вы довольны… а в хранилище всё по-старому.
Деньги в контракте считаются целыми числами вей; дробей нет. Адрес - ключевой тип, а address payable нужен для переводов. Неизменные величины помечают constant и immutable - так дешевле. У сложных типов всегда указывают, где они живут: storage сохраняется, memory временна, calldata - дешёвые входные данные только для чтения. Поля owner, goal, deadline и перечисление State - уже часть нашего краудфандинга.
Контракт знает свою цель и дедлайн, но пока ничего не умеет. Дальше научим его запускать кампанию в конструкторе и отклонять неверные вызовы так, что транзакция откатывается целиком.