Функции: видимость, view, payable и модификаторы
- Зачем это нужно
- Функция — кусок кода с именем, входом и выходом
- Видимость: кто имеет право вызвать
- view и pure: обещания, которые проверяет компилятор
- payable: функция, которая принимает деньги
- Два способа отправить эфир дают разный результат
- Модификатор: проверка, вынесенная за скобки
- Читаем строку из эталонного контракта
- Типичные ошибки
- Слова главы
- Тренажёр: набрать вслепую
- На чемпионате
- Что дальше
- Углубиться
- Куда дальше: проверенные ресурсы
Зачем это нужно
Одна строка из эталонного контракта задания:
function getUserData(address _wallet) public view AccessControl(Role.Owner) returns (User memory)
Здесь пять решений, и каждое меняет поведение: кто может вызвать, платит ли вызывающий, что проверяется до входа в тело, что возвращается наружу. Прочитать такую строку — значит понять функцию, не читая её тела. Это и есть содержание главы.
Заодно выяснится, что private не означает «секретно», что слово view экономит вызывающему все деньги до копейки, а два способа отправить эфир на один и тот же адрес дают разный результат: один проходит, другой откатывает всю транзакцию.
Функция — кусок кода с именем, входом и выходом
function addPure(uint256 a, uint256 b) public pure returns (uint256) {
return a + b;
}
Параметр — это переменная, которая живёт только во время вызова. Вызов addPure(2, 3) кладёт двойку в a, тройку в b, выполняет тело и возвращает пятёрку.
Возвращать можно несколько значений сразу — кортежем. Так сделано в эталонном контракте:
function getBalance() public view returns (uint256, uint256, uint256, uint256) {
return (msg.sender.balance, userMap[msg.sender].seedTokens,
userMap[msg.sender].privateTokens, userMap[msg.sender].publicTokens);
}
Четыре числа за один вызов — вместо четырёх отдельных функций.
Видимость: кто имеет право вызвать
Четыре слова, и выбрать нужно всегда — умолчания нет.
| Слово | Кто может вызвать |
|---|---|
public | все: снаружи, изнутри контракта, из контракта-наследника |
external | только снаружи; изнутри — лишь через this.f() |
internal | только сам контракт и его наследники |
private | только сам контракт |
Наследник — контракт, который берёт содержимое другого и дописывает своё; этим займёмся в отдельной главе.
Первая модель разворачивает контракт с пятью функциями и смотрит, что от него видно снаружи:
── что попало в ABI (список функций, видимых снаружи) ──
Visibility: callHidden(), onlyOutside(), openForAll()
всего функций в контракте: 5, в ABI: 3
── вызовы снаружи, из скрипта ──
openForAll() → 42
onlyOutside() → 42
callHidden() → 84 (internal + private сложились ВНУТРИ)
c.onlyInside — есть ли такое поле у объекта? → undefined
c.onlyHere — есть ли такое поле у объекта? → undefined
попытка вызвать c.onlyHere() из скрипта → c.onlyHere is not a function
── наследник Child ──
useParent() → 42 (internal родителя виден наследнику)
ABI Child: callHidden(), onlyOutside(), openForAll(), useParent()
── байткод: имя функции в цепь не попадает, только 4 байта селектора ──
openForAll 0xc9dc5bc6
onlyOutside 0xb5964cbb
callHidden 0x155f1206
Внутренние функции никуда не делись — callHidden() вернул 84, значит обе отработали. Просто снаружи их нет в ABI, и скрипт не видит у объекта таких полей.
private не значит «секретно». Байткод контракта публичен, и тело приватной функции в нём читается. Скрыт только вход: вызвать снаружи нельзя. Хранить в приватной переменной пароль или ключ — то же самое, что написать его на заборе; в отчёте по информационной безопасности это отдельный пункт.
view и pure: обещания, которые проверяет компилятор
view — функция читает состояние, но не меняет. pure — не читает и не меняет вовсе.
Это не подсказки для читателя, а обещания компилятору. Нарушил — сборка не пройдёт: запись внутри view-функции даёт TypeError: Function cannot be declared as view because this expression (potentially) modifies the state.
Зачем обещание нужно? Вторая модель отвечает деньгами:
── pure и view: вызов (eth_call), не транзакция ──
addPure(2, 3) → 5
addView(5) → 5 (counter пока 0)
counter после двух вызовов: 0
списано с кошелька за оба вызова: 0 wei
── addWrite: транзакция, за неё платят ──
1-я запись (0 → 5): gasUsed = 44230
2-я запись (5 → 10): gasUsed = 27130
разница: 17100 газа — первая запись в пустую ячейку дороже
counter теперь: 10
── что вернула транзакция в скрипт? ──
tx2 — это объект транзакции, а не число. Поле hash: 0x5470a1c1…
returns у пишущей функции снаружи НЕ читается; читают counter(): 10
но то же значение можно получить без транзакции:
addWrite.staticCall(5) → 15 (симуляция, counter не изменился: 10)
── цена того же вызова, если бы addView была транзакцией ──
estimateGas(addView(5)) = 24138 — столько бы стоило, отправь её транзакцией
из них 21000 — базовая цена любой транзакции
Три вывода.
Чтение бесплатно. Пометив функцию view, вы говорите сети: её можно выполнить локально. Узел считает у себя и отвечает — с кошелька не списывается ничего.
Возврат пишущей функции снаружи не прочитать. Транзакция отдаёт объект с хешем, а не число: пока она не попала в блок, результата ещё не существует. Значение читают отдельным view-вызовом. Отсюда привычка: почти к каждому изменению в контракте прилагается функция чтения.
Разница 17 100 газа между первой и второй записью — уже знакомая цена заполнения нулевой ячейки.
payable: функция, которая принимает деньги
По умолчанию функция денег не принимает. Транзакция с эфиром в обычную функцию отклоняется ещё до тела, а обращение к сумме внутри неё не компилируется:
TypeError: "msg.value" and "callvalue()" can only be used in payable public functions.
Слово payable это разрешает, а msg.value говорит, сколько пришло:
function buyToken(uint256 _amount) public payable {
uint256 price = getTokenPrice();
require(msg.value >= _amount * price, unicode"Недостаточно эфира для покупки");
payable(owner).transfer(msg.value);
}
msg.value измеряется в wei — как всегда, целым числом. Проверять его нужно самому: сеть не следит за тем, чтобы прислали ровно столько, сколько стоит покупка.
Два способа отправить эфир дают разный результат
Отправить эфир можно несколькими способами, и выбор между ними — не вкусовщина. Четвёртая модель отправляет один wei одному и тому же получателю двумя способами:
transfer: Transaction reverted: contract call run out of gas and made the transaction revert
call ok, gasUsed = 53842 count = 1
Получатель здесь — контракт, который при приёме денег делает запись у себя. transfer передаёт получателю жёстко 2300 газа — и записи в хранилище в эти 2300 не помещается, поэтому вся транзакция откатывается. call такого ограничения не ставит: перевод прошёл за 53 842 газа, получатель записал единицу.
Правильная запись через call:
(bool ok, ) = payable(to).call{value: amount}("");
require(ok, unicode"Перевод не прошёл");
Проверка ok обязательна: call не откатывает транзакцию сам, он возвращает признак успеха. Забыли проверить — контракт считает перевод выполненным, хотя денег никто не получил.
Именно поэтому документация помечает transfer и send как устаревшие. В эталонном контракте задания стоит payable(owner).transfer(...) — и это готовый пункт для отчёта по информационной безопасности модуля В: «получатель-контракт со своей логикой приёма не сможет получить средства; заменить на call с проверкой результата».
Модификатор: проверка, вынесенная за скобки
Когда одна и та же проверка нужна в десяти функциях, её выносят в модификатор:
modifier AccessControl(Role _role) {
require(userMap[msg.sender].role == _role, unicode"Недостаточно прав");
_;
}
function stopPublicPhase() public AccessControl(Role.Owner) { … }
function giveReward(address to, uint256 amount) public AccessControl(Role.publicProvider) { … }
Третья модель показывает порядок выполнения, который в исходнике не виден:
── владелец назначает alice роль Provider (onlyRole → traced → тело) ──
шаг 1: модификатор: до тела
шаг 2: тело setRole
шаг 3: модификатор: после тела
step в хранилище: 3
── alice вызывает provide() ──
шаг 4: модификатор: до тела
шаг 5: тело provide
шаг 6: модификатор: после тела
step в хранилище: 6
── владелец вызывает provide(), но роль не Provider ──
отказ: reverted with reason string 'Недостаточно прав'
step в хранилище после отказа: 6 — require упал ДО «_;»,
тело и traced не запускались
── alice пытается назначить роль сама себе ──
отказ: reverted with reason string 'Недостаточно прав'
роль alice по-прежнему: Provider
Модификатор оборачивает функцию, а не просто предваряет её: строки до _; идут перед телом, строки после — после тела. Это пригодится в защите от повторного входа, которую разберём в главе про паттерны безопасности.
Модификаторов может быть несколько — тогда они срабатывают слева направо, и отказ в любом откатывает всю транзакцию.
Читаем строку из эталонного контракта
Теперь та самая строка целиком:
function getUserData(address _wallet) public view AccessControl(Role.Owner) returns (User memory)
getUserData— имя;(address _wallet)— принимает адрес.public— вызвать может кто угодно… ноAccessControl(Role.Owner)— модификатор пропустит только владельца. Видимость и права — разные вещи:publicговорит «функция есть в ABI», модификатор — «но сработает не у всех».view— только читает, значит вызов бесплатный.returns (User memory)— возвращает целую структуру. Словоmemoryозначает «копия на время вызова»; про места хранения — в следующей главе.
Одна строка, прочитанная за десять секунд, рассказывает о функции больше, чем её тело.
Типичные ошибки
Забыта видимость. SyntaxError: No visibility specified. Did you intend to add "public"? — слово обязательно.
Строковый параметр без места хранения. TypeError: Data location must be "memory" or "calldata" for parameter in function, but none was given.
Запись внутри view. TypeError: Function cannot be declared as view because this expression (potentially) modifies the state.
msg.value в функции без payable. TypeError: "msg.value" and "callvalue()" can only be used in payable public functions.
transfer на обычном address. TypeError: "send" and "transfer" are only available for objects of type "address payable", not "address".
Результат call не проверен. Компилируется, но перевод может тихо не пройти: require(ok, …) обязателен.
Модификатор без _;. SyntaxError: Modifier body does not contain "_".
Ожидание возвращаемого значения от пишущей функции. Снаружи придёт объект транзакции; значение читают отдельным view-вызовом.
Перегрузка с неоднозначным вызовом. Две функции с одним именем и близкими типами: TypeError: No unique declaration found after argument-dependent lookup.
Слова главы
- Функция (function) — именованный код с параметрами и возвратом.
- Параметр — переменная, живущая только во время вызова.
returns— что функция отдаёт; несколько значений возвращают кортежем.- Видимость:
public— все,external— только снаружи,internal— контракт и наследники,private— только контракт. - ABI — список функций, видимых снаружи;
internalиprivateв него не попадают. - Селектор — 4 байта, по которым сеть находит функцию; имени в цепи нет.
view— только читает;pure— не читает и не меняет. Вызов бесплатен.payable— функция принимает эфир;msg.value— сколько пришло, в wei.transfer/call— два способа отправить эфир; уtransferжёсткие 2300 газа.- Модификатор (modifier) — обёртка над функцией;
_;— место вставки тела. - Перегрузка — одно имя, разные наборы параметров; вызов должен быть однозначным.
Тренажёр: набрать вслепую
Цель: скорость ≥ 100 зн/мин, точность ≥ 88%
function getUserData(address _wallet) public view returns (User memory) {
На чемпионате
Модуль А, 37 баллов. Перечень функций в задании расписан по пунктам: deposit, withdraw, supply, borrow, repay, ликвидация — и у каждой перечислены условия. Практический перевод на язык этой главы:
- каждое условие —
requireс понятным текстом в начале функции; - функции, принимающие деньги, помечены
payable; - функции чтения для дашборда —
view, иначе интерфейс будет требовать транзакцию за каждый показ метрики; - права — модификатором, а не копией проверки в каждой функции.
Отдельное требование задания: «привести достаточные пояснения к работе всех функций смарт-контракта, описание входных параметров и возвращаемых значений». Комментарий над каждой функцией — что делает, что принимает, что возвращает.
Модуль В, 14 баллов. Готовые пункты отчётов из этой главы: transfer вместо call (информационная безопасность), пропущенная проверка результата перевода, функция без модификатора прав там, где он нужен, и функция чтения без view — последнее идёт в отчёт по оптимизации: каждый показ метрики стоил бы пользователю газа.
Что дальше
Функции научились принимать данные и отдавать их. Осталось разобраться, где эти данные лежат: чем хранилище отличается от памяти на время вызова, как устроены отображения и структуры, почему delete в массиве оставляет дыру вместо того, чтобы его укоротить, и как контракт пишет журнал событий для внешнего мира. Это следующая глава — и последняя в блоке про основы Solidity.
Углубиться
calldataвместоmemory— параметр читается прямо из данных транзакции без копирования; на длинных строках и массивах экономит заметно.- Именованные возвращаемые значения —
returns (uint256 total): присвоил переменной и можно писатьreturnбез аргументов. this.f()— вызов собственной функции «снаружи»; нужен дляexternal, стоит дороже обычного.- Функции получения эфира —
receive()иfallback(): что вызывается, когда деньги приходят без указания функции. - Перегрузка и селекторы — почему у
f(uint256)иf(uint8)разные четыре байта и как это связано с ABI.
Сводная проверка по всей главе: 8 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.
Куда дальше: проверенные ресурсы
- Видимость и геттеры — docs.soliditylang.org — четыре уровня и что попадает в ABI.
- Модификаторы функций — синтаксис,
_;, порядок нескольких модификаторов. - View- и pure-функции — полный список того, что считается изменением состояния.
- Члены типа address —
transfer,send,callи предупреждение об устаревании. - Отправка эфира: solidity-by-example — три способа рядом, с кодом.
Комментарии
Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.