Перейти к основному содержимому
Блокчейн10ГЛАВА 10Наследование: is, virtual, override и superERC20decimals18override12взять и дописать

Наследование: is, virtual, override и super

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

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

Первая строка кода в эталонном контракте задания устроена не так, как всё, что мы писали до сих пор:

contract Token is ERC20("CryptoMonster", "CMON")

Свой контракт получает всё, что умеет чужой: балансы, переводы, выпуск токенов, события. Написано это одним словом — is. А ниже в том же файле стоит

function decimals() public view virtual override returns (uint8) { return 12; }

Три слова подряд, каждое из которых обязательно. Разберём, что они означают, — и заодно увидим, как одна строка в конструкторе базового контракта выпускает разное количество токенов в зависимости от того, кто её унаследовал.

Слово is читается как «является»​

contract MiniERC20 { … } // базовый контракт
contract Token is MiniERC20 { … } // наследник

Базовый контракт (base) — тот, у кого берут. Наследник (derived) — тот, кто берёт и дописывает. Всё public и internal из базового становится доступно внутри наследника напрямую, без всяких префиксов: пишете balanceOf(owner) — и это работает, хотя функция объявлена в другом контракте.

Базовых контрактов может быть несколько — тогда их перечисляют через запятую.

Интересный факт

В эталонном контракте задания на этом месте опечатка: contract Token are ERC20(...). Компилятор отвечает ParserError: Expected '{' but got identifier и ставит стрелки под словом are. Это первая строка отчёта по синтаксису в модуле В.

Базовый токен пишем сами​

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

contract MiniERC20 {
string public name;
string public symbol;
uint256 public totalSupply;
mapping(address => uint256) public balanceOf;

event Transfer(address indexed from, address indexed to, uint256 value);

constructor(string memory _name, string memory _symbol, uint256 _supply) {
name = _name;
symbol = _symbol;
_mint(msg.sender, _supply * 10 ** decimals());
}

function decimals() public view virtual returns (uint8) {
return 18;
}

function transfer(address to, uint256 value) public returns (bool) {
_transfer(msg.sender, to, value);
return true;
}

function _transfer(address from, address to, uint256 value) internal { … }
function _mint(address to, uint256 value) internal { … }
}

Функции с подчёркиванием в начале имени помечены internal: они доступны самому контракту и наследникам, но не снаружи. Это соглашение об именовании — подчёркивание намекает читателю «служебное».

Аргументы базовому конструктору​

Если у базового контракта конструктор с параметрами, наследник обязан их передать. Два способа:

// 1. Прямо в списке is — когда значения известны заранее (так в эталоне КЗ)
contract Token12 is MiniERC20("CryptoMonster", "CMON", 10_000_000) { … }

// 2. В заголовке своего конструктора — когда значения приходят при развёртывании
contract Square is Shape {
constructor(uint256 _side) Shape("square") { side = _side; }
}

Забыли передать — TypeError: No arguments passed to the base constructor. Specify the arguments or mark "Token" as abstract. Передали дважды, обоими способами сразу, — тоже ошибка.

virtual и override: разрешение и подтверждение​

// в базовом контракте
function decimals() public view virtual returns (uint8) { return 18; }

// в наследнике
function decimals() public view virtual override returns (uint8) { return 12; }
Нажми на часть выражения

Зачем два слова вместо одного? Это защита от случайности. Без virtual в базе автор наследника не может незаметно подменить поведение чужого кода; без override в наследнике совпадение имён не превратится в тихую подмену.

Конструктор базы вызывает функцию наследника​

Первая модель разворачивает два наследника одного базового токена: один оставляет decimals() как есть, другой возвращает 12 — как в эталонном контракте.

── один и тот же базовый контракт, два наследника ──
E18: decimals() = 18, 1 токен = 1000000000000000000 минимальных единиц
CMON: decimals() = 12, 1 токен = 1000000000000 минимальных единиц
разница: в 1000000 раз

── конструктор базового контракта вызвал decimals() наследника ──
оба создавались с _supply = 10 000 000
totalSupply E18 = 10000000000000000000000000
totalSupply CMON = 10000000000000000000
код _mint один и тот же, число разное — decimals() подменил наследник

── переводим «1 токен», посчитанный по привычке как 10^18 ──
ушло минимальных единиц: 1000000000000000000
у получателя CMON: 1000000.0
в 18-значном токене это был бы 1 токен, в 12-значном — миллион

── правильно: считать через decimals() контракта ──
ещё один перевод на 1000000000000 единиц → у получателя 1000001.0 CMON
ОДНА СТРОКА В БАЗЕ — ДВА РАЗНЫХ РЕЗУЛЬТАТАконструктор базового контракта_mint(msg.sender, supply * 10 ** decimals())наследник с decimals() = 181 токен = 10¹⁸ единицtotalSupply = 10²⁵наследник с decimals() = 121 токен = 10¹² единицtotalSupply = 10¹⁹код _mint один и тот же — число разное: decimals() подменил наследник, даже вызванный из конструктора базы«1 токен», посчитанный по привычке как 10¹⁸, доставит в 12-значном токене миллион вместо одного
Строка _mint написана в базовом контракте, но количество зависит от наследника: виртуальный вызов уходит вниз даже из конструктора

Главное здесь — вторая часть вывода. Строка _mint(msg.sender, _supply * 10 ** decimals()) написана в конструкторе базового контракта. Но decimals() виртуальная, и вызов уходит к наследнику. Один и тот же код выпустил 10²⁵ единиц в одном случае и 10¹⁹ — в другом.

Ровно так устроена и строка uint256 dec = 10**decimals(); в эталонном контракте задания.

Третья часть — про ошибку на демонстрации. Привычка «один токен — это 10¹⁸» в 12-значном токене превращает один токен в миллион. Правило: количество всегда считают через decimals() самого контракта, а не по памяти.

Отвечено вопросов: 0 из 2
Зачем нужны два слова — virtual в базе и override в наследнике?
Строка _mint(…, supply * 10 ** decimals()) стоит в конструкторе базы. У наследника decimals() = 12. Сколько выпустится?
Отвечено верно: 0 из 2

Порядок конструкторов и слово super​

Когда наследников несколько уровней, важен порядок. Вторая модель строит «ромб»: общий предок A, от него B и C, от них обоих — D.

── D1: конструкторы (порядок событий при деплое) ──
constructor A
constructor B
constructor C
constructor D1
── D1.who(): цепочка super ──
who: D1
who: C
who: B
who: A
событий: 4, A вызван один раз; газ who(): 28430

── D2: конструкторы (порядок событий при деплое) ──
constructor A
constructor C
constructor B
constructor D2
── D2.who(): цепочка super ──
who: D2
who: B
who: C
who: A

── вывод ──
контракт D1 is B, C: super в C идёт в B, хотя в коде C написано только «is A»
контракт D2 is C, B: super в B идёт в C — порядок задаёт список после is
конструкторы всегда: базовые сначала, наследник последним; каждый ровно один раз
ПОРЯДОК В СПИСКЕ IS ЗАДАЁТ ЦЕПОЧКУ SUPERABCDD is B, C → who(): D → C → B → AD is C, B → who(): D → B → C → Asuper — «следующий в очереди», а не «мой родитель»: в C он уходит в B, хотя в коде C написано только is Aконструкторы всегда идут от базовых к наследнику, и общий предок выполняется ровно один раз
super — «следующий в очереди», и очередь задаётся списком после is; общий предок выполняется один раз

Два правила.

Конструкторы идут от базовых к наследнику, и общий предок выполняется ровно один раз, сколько бы путей к нему ни вело. Четыре события, а не пять.

super — это «следующий в очереди», а не «мой родитель». Интуиция подсказывает, что super внутри C уйдёт в A — ведь в коде C написано is A. На деле в D is B, C цепочка идёт D → C → B → A. Порядок задаёт список после is у самого нижнего контракта, и переставив в нём два имени, вы переставляете вызовы.

Отсюда правило записи: после is перечисляют от самого базового к самому производному. Нарушение даёт TypeError: Linearization of inheritance graph impossible.

abstract: заготовка, которую нельзя развернуть​

Если у функции есть только заголовок и точка с запятой, контракт неполон:

abstract contract Shape {
string public kind;
uint256 internal scale = 2;

constructor(string memory _kind) { kind = _kind; }

function area() public view virtual returns (uint256); // тела нет

function describe() public view returns (string memory, uint256) {
return (kind, area() * scale);
}
}

Слово abstract означает «развернуть нельзя, только унаследовать». Забыли его — TypeError: Contract "Shape" should be marked as abstract.

Неочевидное: describe() вызывает area(), которой у самого Shape нет. И это работает — потому что к моменту развёртывания наследник её обязательно допишет. Модель показывает результат: Square(7).describe() даёт 98 — это площадь 49 от наследника, умноженная на scale = 2 из базового контракта.

private против internal — вот ради чего они разные​

В главе про функции было четыре уровня видимости. Наследование — та самая разница, ради которой существуют два из них:

  • internal — видно самому контракту и наследникам;
  • private — только самому контракту.

Наследник, который попробует прочитать приватное поле базового контракта, получит DeclarationError: Undeclared identifier. — для него этого имени просто нет.

Восемь ошибок наследования​

Третья модель прогоняет восемь заведомо сломанных контрактов через настоящий компилятор и печатает тексты:

1. в базе нет virtual, наследник переопределяет
TypeError: Trying to override non-virtual function. Did you forget to add "virtual"?

2. в наследнике нет override
TypeError: Overriding function is missing "override" specifier.

3. наследник читает private-поле базового
DeclarationError: Undeclared identifier.

4. неверный порядок в is: производный раньше базового
TypeError: Linearization of inheritance graph impossible

5. функция без тела, а abstract не написано
TypeError: Contract "Shape" should be marked as abstract.

6. базовому конструктору нужны аргументы, а их не передали
TypeError: No arguments passed to the base constructor.
Specify the arguments or mark "Token" as abstract.

7. функция есть в двух родителях, override без списка
TypeError: Function needs to specify overridden contracts "B" and "C".

8. опечатка из эталона КЗ: are вместо is
ParserError: Expected '{' but got identifier
ВОСЕМЬ ОШИБОК НАСЛЕДОВАНИЯ — ДОСЛОВНО ОТ КОМПИЛЯТОРАв базе нет virtualTrying to override non-virtual functionв наследнике нет overrideOverriding function is missing "override" specifierнаследник читает private базыDeclarationError: Undeclared identifierпроизводный раньше базового в isLinearization of inheritance graph impossibleфункция без тела, abstract не написанContract "Shape" should be marked as abstractбазовому конструктору не дали аргументыNo arguments passed to the base constructorфункция есть в двух родителяхFunction needs to specify overridden contracts "B" and "C"are вместо is (опечатка из эталона КЗ)ParserError: Expected '{' but got identifier
Восемь способов сломать наследование и точный текст компилятора для каждого

Седьмая строка — единственная, которую стоит пояснить. Если одна и та же функция объявлена у двух родителей, наследник обязан перечислить обоих:

function who() public view override(B, C) returns (string memory) { … }

Компилятор требует явности: он не станет угадывать, какую версию вы имели в виду.

Отвечено вопросов: 0 из 2
D is B, C. В коде C написано «is A». Куда уйдёт super из C?
Наследник читает private-поле базового контракта. Что скажет компилятор?
Отвечено верно: 0 из 2

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

are вместо is. ParserError: Expected '{' but got identifier — строка из эталонного контракта.

Нет virtual в базе. Trying to override non-virtual function. Did you forget to add "virtual"?

Нет override в наследнике. Overriding function is missing "override" specifier.

Аргументы базовому конструктору не переданы. No arguments passed to the base constructor.

Аргументы переданы дважды — и в списке is, и в заголовке конструктора: Base constructor arguments given twice.

Неверный порядок в is. Linearization of inheritance graph impossible; перечислять надо от базового к производному.

Определение наследника раньше базового в файле. Definition of base has to precede definition of derived contract.

private вместо internal. Наследник не видит поле — Undeclared identifier.

Расчёт количества токенов по памяти. Ошибки нет вообще: перевод «одного токена» как 10¹⁸ в 12-значном токене отправляет миллион. Считать через decimals().

Слова главы​

  • is — «является»: наследник получает содержимое базового контракта.
  • Базовый контракт (base) и наследник (derived).
  • virtual — в базе: функцию можно переписать; override — в наследнике: переписываю.
  • override(B, C) — когда функция пришла от двух родителей.
  • super — следующий в очереди наследования, а не обязательно прямой родитель.
  • Линеаризация — порядок, в котором выстраиваются контракты; задаётся списком после is.
  • abstract contract — заготовка с функциями без тела; развернуть нельзя, только унаследовать.
  • internal против private — видно наследникам или только самому контракту.
  • Аргументы базовому конструктору — в списке is либо в заголовке своего конструктора, но не дважды.

Тренажёр: набрать вслепую​

Цель: скорость ≥ 100 зн/мин, точность ≥ 88%

contract Token is MiniERC20("CryptoMonster", "CMON", 10_000_000) {

На чемпионате​

Модуль А, 37 баллов. Требование задания сформулировано прямо: «все токены системы, кроме shares token, соответствуют стандарту ERC20; shares token соответствует стандарту ERC4626». На языке этой главы каждый токен системы — это contract X is ERC20 с переопределённым decimals() и своими именем и символом из таблицы метаданных.

В задании 2026 года таких токенов несколько: USDC и залоговые токены по стандарту ERC20, доли хранилища — по ERC4626. Все они строятся одинаково: базовый контракт плюс наследник с параметрами конструктора.

Модуль В, 14 баллов. Из этой главы в отчёты идут:

  • Синтаксис: are вместо is — первая же строка эталонного контракта.
  • Логика: расчёты, где количество токенов посчитано без учёта decimals().
  • Функционал: описание, что именно наследник переопределил и зачем — это и есть «пояснения к работе функций», которых требует задание.
Отвечено вопросов: 0 из 2
Как по заданию строится токен со своими именем и точностью?
Перевод «одного токена» посчитали как 10^18, а decimals() контракта = 12. Что произойдёт?
Отвечено верно: 0 из 2

Что дальше​

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

Отвечено вопросов: 0 из 1
Сколько раз выполнится конструктор общего предка при ромбовидном наследовании?
Отвечено верно: 0 из 1

Углубиться​

  • Как спросить порядок линеаризации у компилятора — в разобранном дереве кода есть поле со списком базовых контрактов в вычисленном порядке.
  • Виртуальные модификаторы — modifier m() virtual и override для них тоже работают.
  • Множественное наследование без ромба — когда родители не пересекаются, порядок почти не важен; проблемы начинаются с общего предка.
  • Переопределение переменных — почему public-переменную нельзя переопределить, а функцию-читалку можно.
  • Композиция вместо наследования — когда лучше держать чужой контракт по адресу, а не наследовать его; про это следующая глава.

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

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

Видео по теме

ВидеоSolidity и смарт-контракты Ethereum, урок #11 | Наследование, конструкторы, supeIlya Krukowski
ВидеоSolidity | Урок 6. Наследование, require, importАртем Кукуруза
ВидеоОсновы Solidity: Наследование | Введение в разработку смарт-контрактовConflux Russian
ВидеоСоздаем свой токен ERC 20 в Solidity с помощью библиотеки Openzeppelin. EthereumAlex Klimov
ВидеоInheritance | Solidity 0.8Smart Contract Programmer

Комментарии

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