$ sudo teach IT
Модуль 2 из 7

Типы данных: чем описать кампанию

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» существуют только в интерфейсе кошелька, для людей. А чтобы не пересчитывать нули вручную, есть литералы с единицами:

solidity
uint256 goal = 10 ether;   // то же самое, что 10 * 10**18 вей
uint256 tip  = 100 gwei;   // 100 * 10**9 вей
Переполнение: что изменилось в 0.8

Вспомните механический счётчик пробега: докрутили до 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 этими методами нельзя - компилятор не пропустит.
solidity
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 вшиваются прямо в код контракта, и чтение обходится почти даром. К тому же компилятор не даст их случайно перезаписать.

solidity
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) объединяет несколько значений в один именованный тип - удобно, чтобы описать «запись о вкладчике» или «состояние кампании» одним блоком:

solidity
struct Contributor {
    uint256 amount;      // сколько внёс
    uint256 lastDonation; // время последнего вклада
}

Перечисление (enum) задаёт ограниченный набор именованных состояний. Под капотом это маленькое целое, но в коде читается куда понятнее, чем «статус = 2»:

solidity
enum State { Active, Succeeded, Failed }
State public state;   // по умолчанию - первое значение, Active

Перечисление идеально описывает жизненный цикл кампании: пока идёт сбор - Active, цель взята - Succeeded, время вышло без цели - Failed. К этому автомату состояний мы вернёмся в модуле 6.

2.6Где живут данные: storage, memory, calldata

Вот где новички спотыкаются чаще всего: у сложных типов нужно указывать «место хранения». Компилятор тут не придирается - от этой пометки зависит, сохранятся ли данные и сколько газа сгорит.

Область Живёт Смысл
storage В блокчейне, постоянно Переменные состояния контракта. Запись - самая дорогая операция. Переживает транзакции.
memory В оперативной памяти вызова Временные данные на время одной функции. Дешевле storage, стираются после выхода из функции.
calldata В данных транзакции, только чтение Аргументы, пришедшие извне. Нельзя менять, зато самый дешёвый способ их читать.
solidity
// аргумент только читаем - берём 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 - уже часть нашего краудфандинга.

Дальше · модуль 3

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