Распутываем виртуальные методы в SystemVerilog
PDF версию статьи ищи здесь
Введение
Я - один из тех странных людей, которые время от времени пишут ответы на r/systemverilog .Однажды я увидел там довольно простой с моей точки зрения вопрос: что такое виртуальный метод? Затянувшееся обсуждение показало, что вопрос не такой уж и простой, если вы пришли в SystemVerilog без опыта ООП в других языках. Наследование, переопределения методов, работа ссылочных типов - все эти концепции нужно понимать, чтобы вникнуть в работу виртуальных методов.
Попробуем разобраться.
Обращаясь к стандарту языка, будем иметь в виду 1800-2017 - IEEE Standard for SystemVerilog. Проверяться код будет в четырёх симуляторах: Riviera, VCS, Xcelium, Questa, однако для воспроизведения примеров достаточно EDA Playground без корпоративного аккаунта.
А зачем всё это?
Представим себе драйвер, который принимает пакеты и передаёт их по интерфейсу в виде битового потока. Пакеты могут быть типов data
и control
, каждый тип имеет свой набор полей, характеризующий пакет. Было бы здорово написать такой код в драйвере, который бы обработал сразу и data
и control
, да ещё чтобы не менять драйвер, когда у нас появится десяток новых типов пакетов. Такой код может выглядеть так:
|
Метод drive_pkt
может иметь аргументом любого наследника класса packet
. Каждый наследник имеет свою реализацию метода to_raw
. При добавлении новых типов пакетов нужно только реализовать этот метод в новом классе, а трогать код драйвера необходимости нет.
Чтобы эта схема работала, метод to_raw
должен быть виртуальным.
Как работают виртуальные методы
Попытка понять первая, прямая
Начнём с определения, которое нам даёт стандарт.
A method of a class may be identified with the keyword virtual. Virtual methods are a basic polymorphic construct. A virtual method shall override a method in all of its base classes, whereas a non-virtual method shall only override a method in that class and its descendants. One way to view this is that there is only one implementation of a virtual method per class hierarchy, and it is always the one in the latest derived class.
Перевод:
Метод класса может быть обозначен ключевым словом virtual. Виртуальные методы - это базовая полиморфная конструкция. Виртуальный метод переопределяет метод во всех своих базовых классах, в то время как невиртуальный метод переопределяет метод только в этом классе и его потомках. Иными словами, существует только одна реализация виртуального метода в иерархии классов, и это всегда реализация в последнем классе-наследнике.
Вам понятно? Мне - не очень. Но давайте зацепимся за последнее предложение, оно выглядит несложно:
Иными словами, существует только одна реализация виртуального метода в иерархии классов, и это всегда реализация в последнем классе-наследнике.
То есть виртуальный метод класса-наследника переопределяет виртуальный метод класса-родителя, и у какого бы объекта мы этот метод ни вызвали, всё равно будет использована реализация последнего наследника? Проверим.
|
Вывод:
|
Выходит, что всё-таки не одна реализация метода my_virtual_name
существует. Так что же, стандарт обманывает нас?
Дело в том, что в стандарте определение написано таким сказочным образом, что может намекать на настоящее поведение виртуальных методов только тем, кто его уже понимает. Поэтому дадим своё определение. >При вызове виртуального метода выбирается реализация, принадлежащая типу объекта, находящемуся по ссылке, а не типу ссылки.
Если всё ещё не понятно, не волнуйтесь. Далее мы подробно разберём что к чему. А пока - пример, демонстрирующий “виртуальность” методов. Возьмём код из предыдущего примера и изменим блок initial
.
|
Вывод:
|
Хотя ссылка имеет тип Foo
, она указывает на объект типа Bar
, чей метод my_virtual_name()
и был вызван, в то время как невиртуальный my_common_name()
был вызван согласно типу ссылки.
Попытка понять вторая, обходная
Крайне важно понять разницу между ссылочными типами (reference type) и типами значения (value type).
При объявлении целочисленной переменной
|
переменная x
это и есть число 4. Мы можем присвоить ей другое значение, тогда эта переменная станет другим числом. Оператор присваивания в данном случае можно считать записью числа справа в ячейку памяти слева.
Всё сложнее при объявлении объекта.
|
Здесь foo
- это не объект, а ссылка на объект. Объект типа Foo
находится где-то в памяти, а ссылка foo
указывает на это место в памяти. Тип ссылки не обязан совпадать с типом объекта, на который она указывает. Это может быть также объект любого типа, который располагается ниже в иерархии классов. То есть, любой тип-наследник.
Foo foo; Bar bar = new(); foo = bar; |
В обратную сторону это не работает. Ссылка не может указывать на объект класса выше в иерархии наследования, чем тип ссылки.
|
Можно провести такую аналогию: ссылка - это коробка, а объект в эту коробку помещается оператором присваивания. Наследник базового класса - это объект поменьше, и коробка у него тоже поменьше. В примере выше foo = bar
значит “достать объект из коробки bar
и положить его в коробку foo
”. Выполнить bar = foo
невозможно, потому что в коробке foo
в данный момент лежит слишком большой объект, он не влезет в bar
.
В чём отличие виртуальных методов от невиртуальных в нашей аналогии? При вызове невиртуального метода симулятор смотрит только на коробку, а при вызове виртуального – на содержимое коробки.
Чуть больше деталей и чуть меньше коробок
Немного пояснений для тех, кому интересно происходящее под капотом.
При каждом вызове функции симулятору нужно знать, по какому адресу в памяти расположен код функции. Если метод невиртуальный, то эта информация известна на этапе компиляции, и там, где в исходном коде вызывается метод, в исполняемом коде появляется переход на адрес памяти с этим методом. Это называется ранним связыванием.
Если метод виртуальный, то во время компиляции для раннего связывания компилятору нужно знать тип объекта по ссылке, что возможно далеко не всегда. Поэтому для каждого класса с виртуальными методами создаётся таблица виртуальных функций (vtable), содержащая пары индекс - адрес метода. В сам класс добавляется указатель на эту таблицу (vpointer). Имя метода в точке вызова превращается в индекс внутри таблицы. Используя указатель на таблицу и индекс внутри таблицы, симулятор находит адрес виртуального метода.
У меня нет полной уверенности, что виртуальные методы в SystemVerilog работают именно так, однако примерно так они работают в C++ и подобных языках. Учитывая близость SystemVerillog к C++, можно с большой долей уверенности считать, что данное объяснение релевантно.
Более запутанные случаи
Вызов метода из объекта
Надеюсь, что с вызовом виртуальных методов через ссылки теперь всё встало на свои места. Что насчёт вызова виртуальных методов внутри самого объекта?
В следующем примере мы будем вызывать виртуальную функцию внутри невиртуальной. Если вспомнить, что внутри объекта обращение к его методам происходит через неявную ссылку this
, то можно предположить, что будет действовать всё та же логика и вызываться будет виртуальный метод согласно типу объекта. Проверим.
|
Что, по-вашему, будет выведено? Посмотрите, с каким из симуляторов вы согласны.
Simulator | Output |
---|---|
VCS | bar bar |
Questa | foo bar |
Xcelium | bar bar |
Riviera | foo bar |
Вызов x.wen()
во всех симуляторах напечатал bar
, как и ожидалось. Что же случилось при вызове конструктора?
По правилам работы виртуальных методов, должен был быть вызван метод my_name()
из класса bar
, однако на момент вызова мы находились в конструкторе класса foo
, а класс bar
ещё даже не начал создаваться, что делает ситуацию неоднозначной.
Если вам кажется, что VCS и Xcelium просто оказались чуточку умнее, я буду вынужден вас расстроить. Это поведение опасно. Давайте добавим ещё один класс baz
.
|
Полный код: https://edaplayground.com/x/MFhz
|
То есть, на момент вызова super.new()
поле baz_o
ещё не было инициализировано и равно null
. Если запустить симуляцию в VCS, то она упадёт с ошибкой Null Object Access. Та же участь ожидает и Xcelium. Riviera и Questa чувствуют себя прекрасно, так как и не пытаются обратиться к чужой переменной.
В С++ механизм виртуальных методов выключен в конструкторе для защиты именно от такой опасности. Следуют ли разработчики Riviera и Questa этой практике осознанно, или просто так получилось, я не знаю.
Так какой же симулятор поступил правильно? Который следовал стандарту буквально, или который не дал выстрелить в ногу? Точно можно сказать, что поступили неправильно: 1. стандарт, который выложил такие прекрасные грабли, никак не указав на случай, когда виртуальные методы просто не могут работать как должны; 2. разработчик, который на эти грабли радостно наступил.
То есть просто не нужно вызывать виртуальные методы в конструкторе и всё у вас будет хорошо.
Но что же делать, если вызвать виртуальный метод в конструкторе очень хочется? Как правило, такая задача возникает при инициализации объекта. Раз виртуальный метод нельзя использовать в конструкторе, нужно его оттуда вынести. Вопрос лишь в том, куда. Рассмотрим два возможных варианта.
Можно всякий раз при создании объекта вызывать необходимый виртуальный метод.
|
Предельно простое решение, однако далеко не безопасное: слишком легко забыть о том, что нужно ещё что-то кроме создания.
Безопаснее будет добавить в класс статический метод и создавать объект с его помощью, а конструктор сделать protected
.
|
Теперь мы не сможем создать объект, но не вызвать my_name()
. Минус этого подхода в том, что такой класс нельзя использовать с UVM фабрикой.
Какой способ выбрать, решайте по обстоятельствам. Можно, конечно, нафантазировать и другие, более сложные и универсальные подходы к решению задачи, но это тема для отдельной статьи.
Меняем сигнатуру метода
При переопределении метода его входные аргументы и возвращаемый тип не обязаны точно совпадать с родительскими. Ниже слегка изменённый пример из пункта 8.20 стандарта.
|
При переопределении допускается заменить тип возвращаемого значения либо на наследника, если это класс, либо на совпадающий тип (matchig type). Если грубо, то совпадающий тип - это тот же тип под другим именем (сравните int
и MyInt
), но есть нюансы со структурами. Подробно об этом в пункте 6.22.1 стандарта.
Тип входных аргументов можно заменить на совпадающий тип.
Допускается также при переопределении добавлять и опускать ключевое слово virtual
. https://edaplayground.com/x/GZbg
|
В классе A
метод не был виртуальным и при использовании ссылки типа A
будет вести себя как невиртуальный. Однако после его переопределения виртуальным в классе B
он станет виртуальным во всех наследниках, независимо от наличия модификатора virtual
.
Вывод:
|
С точки зрения чистоты кода, лучше virtual
не опускать: не заставляйте других просматривать всю иерархию в поисках одного слова. Исключение составляют, быть может, совершенно очевидные случаи типа фаз UVM. И так все знают, что они виртуальные.
Вызов конкретной реализации метода
Несмотря на уверения стандарта о существовании только одной реализации метода, и на вызов метода согласно типу объекта, существует случай, когда вызывается реализация не объекта, а родителя. И это просто super
.
https://edaplayground.com/x/WbsT
|
Точно так же, как и в невиртуальных методах, будет вызвана реализация родителя.
|
При переопределении метода иногда бывает необходимо вызвать реализацию не super
, а super.super
. Это бывает, когда прародитель выполняет инициализацию, а поведение родителя мы хотим переопределить и вообще не вызывать. Писать super.super
нельзя, однако задача решается легко.
|
Вывод, с которым согласны все симуляторы.
|
То есть оператор ::
позволяет вызвать реализацию конкретного класса. Но сейчас мы находились в том же методе, чью реализацию выбирали. А можно ли вызвать реализацию метода вне этого метода? Добавим в класс Foo
невиртуальную функцию и попытаемся вызвать Foo::my_name
.
|
Simulator | Output |
---|---|
VCS | Foo Bar Baz |
Questa | Foo |
Xcelium | Foo |
Riviera | Foo Bar Baz |
Мнения разделились. Questa и Xcelium вызвали метод указанного класса, тогда как VCS и Riviera вспомнили, что метод-то ещё и виртуальный, и вызвали его как таковой. С одной стороны, это ближе к правилам работы виртуальных методов, а с другой - совсем не то, чего хотел автор. Впрочем, делать метод виртуальным и полагаться на вызов его конкретной реализации - не самая лучшая идея. Нужно выбрать что-то одно.
Применение виртуальных методов
Разобравшись в работе виртуальных методов, перейдём к вопросу их области применения.
Полиморфизм
Этим словом называется способность функции обрабатывать различные типы данных. Это именно то, что мы хотели для обработки пакетов в начале статьи. Дополним тот код.
|
Сейчас вам должно быть ясно, почему метод to_raw
должен быть виртуальным и как будет работать драйвер. Отметим детали, которых мы не касались ранее.
С ключевым словом virtual
у нас объявлен сам базовый класс. Это означает, что объект этого класса нельзя создать, то есть следующий код не может быть скомпилирован.
|
Такой класс называется абстрактным. Абстрактные классы удобно использовать в тех случаях, когда без дополнительных деталей класс настолько неполный, что не имеет никакого смысла создавать и использовать его объекты. Если пакет всегда либо с данными, либо управляющий, то существование во время симуляции пакета, который ни то, ни другое - это ошибка, которую нужно обрабатывать. Вместо этого можно объявить класс абстрактным и сделать такую ошибку невозможной.
Метод to_raw
в базовом классе packet
объявлен как pure virtual
. Это значит, что класс не предоставляет реализацию этого метода, однако его неабстрактные наследники обязаны иметь реализацию этого метода. Иными словами, реализацию обязан предоставить первый неабстрактный наследник в иерархии.
Переопределение поведения
Иногда бывает нужно дополнить или изменить поведение готовых и отлаженных компонентов. Может быть, что в стандартный интерфейс пришлось добавить новый сигнал и требуется дополнить драйвер. Или испортить в тесте корректные транзакции, чтобы проверить обработку ошибок у DUT. В обоих случаях желательно не копипастить код, который можно переиспользовать,
Обе задачи можно решить с помощью полиморфизма и фабрики. Попробуем подменить транзакции. https://edaplayground.com/x/aKcd
|
Здесь мы ждём отправку четырёх оригинальных транзакций, с помощью фабрики заменяем тип на транзакцию с ошибкой и теперь в блоке always
будет создаваться error_transaction
. Ещё через две транзакции заменяем тип обратно.
|
Ключевой момент в том, что работающий с транзакциями код менять не понадобилось. Да, в таком простом примере это было бы несложно, но в большом тестбенче всё становится не столь однозначно.
Фабрика немного возмущается при возвращении оригинального типа. Это ожидаемо для UVM-1.2, а вот UVM-1.1d не сможет вернуть тип обратно из-за бага в фабрике.
Задача с драйвером решается аналогично: наследуемся от базового драйвера, переопределяем нужные методы, которые должны быть виртуальными, используем фабрику для переопределения типа создаваемого драйвера. Оригинальный код агента менять в данном случае не нужно, он полностью переиспользуется. Переопределить тип в фабрике можно откуда угодно. Хорошие места для этого - базовый тест и окружение (environment).
Помимо фабрики существуют и другие способы переопределения поведения. Здесь мы просто иллюстрируем возможность, а рассуждение о целесообразности использования фабрики или иного метода оставим для другой статьи.
Интерфейсные классы
Это обширная и непростая тема, заслуживающая отдельной статьи. Поэтому мы коснёмся её лишь вкратце.
Интерфейсный класс представляет собой набор чисто виртуальных методов. Отличие от абстрактного класса заключается в том, что интерфейсный класс не имеет свойств (только методы), и не наследуется, а реализуется. Каждый класс может наследовать только один класс, однако реализовывать любое количество интерфейсных классов.
Как и с абстрактными классами, любой класс, реализующий интерфейс, должен предоставлять реализации всех чисто виртуальных методов интерфейсного класса.
Объекты всех классов, реализующих некоторый интерфейсный класс, можно использовать как объекты этого интерфейсного класса, что открывает новые возможности использования полиморфизма.
За подробностями можно обратиться к пункту 8.26 стандарта, а примеры использования можно увидеть в этой замечательной статье.
Где использовать не стоит
Рассмотрим, наконец, случаи, где использование виртуальных методов будет только мешать.
Во-первых, стандарт допускает использовать невиртуальные методы в качестве выражений wait
и @
|
Стандарт явно не запрещает использовать таким образом виртуальные методы, поэтому у меня нет претензий к Xcelium, Questa и Riviera, которые компилируют эти выражения с виртуальным методами. Отказался лишь VCS. Но можно обернуть виртуальный метод в невиртуальный, и тогда компиляция пройдёт.
Во-вторых, вспомним о проблеме вызова виртуального метода в конструкторе. Если очень нужно вызвать метод именно там и обходные пути не подходят, то делать метод виртуальным не стоит. Даже если вы знаете, как ведёт себя ваш симулятор, в следующей версии всё может поменяться.
В-третьих, корректность работы класса может зависеть от правильной реализации метода. Если такой метод будет виртуальным, то при его переопределении в наследнике можно сломать работу базового класса. Мне не встречалось в практике таких случаев, но можно пофантазировать.
Если в классе для работы с псевдослучайными последовательностями переопределить метод генерации следующего числа, не имея больших познаний в данной области математики, можно испортить качество последовательности. При этом на первый взгляд всё продолжит работать, но требования к качеству входных данных будут нарушены.
Если вы разрабатываете VIP на продажу, то встаёт вопрос лицензирования. Если проверка лицензии реализована в методе класса, то очень бы не хотелось, чтобы кто-то переопределил её на return 1
.
А вот в Java вообще всё виртуальное…
Виртуальные методы позволяют нам использовать гибкость полиморфизма, а вот ситуаций, где методы должны быть строго невиртуальными, кажется, совсем немного. Так может стоит все методы сразу объявлять виртуальными? Это отличная тема для холивара, но я сам придерживаюсь положительного ответа на этот вопрос.
В моей практике мне ни разу ещё не приходилось убирать модификатор virtual
, но приходилось его добавлять. Ну кому и зачем, думал я, может понадобиться переопределять вот этот вот метод? В результате я сам, полугодом позже переопределяю этот метод, позабыв о его невиртуальности в базовом классе и получаю неожиданное поведение тестбенча. А можно было бы и не тратить время на отладку, сразу объявив метод виртуальным.
Делать все методы виртуальными или нет - вопрос, на который нужно отвечать самому, используя свой опыт и специфику работы. Однако есть один объективный контраргумент против разгула виртуальности - производительность.
Эксперименты
Если вы заглянули в раздел с делателями реализации виртуальных методов, то вам должны были броситься в глаза дополнительные накладные расходы на вызов виртуального метода по сравнению с невиртуальным.
Попробуем выяснить, насколько всё плохо.
|
Сразу же сделаем несколько пояснений по коду.
- Достаточно умный компилятор в некоторых случаях может заметить, что в момент вызова виртуального метода тип класса известен однозначно. В этом случае позднее связывание можно заменить ранним. Чтобы исключить такую оптимизацию, мы выбираем класс случайным образом.
- Класс выбирается до начала эксперимента. Во-первых, если класс выбирается перед каждым вызовом метода, скорее всего по смыслу нужен именно виртуальный метод. Во-вторых, не станем раздувать и того большую статью и оставим вариации эксперимента очумелым ручкам.
На момент написания статьи у меня нет возможности запускать все симуляторы на одном и том же железе, поэтому я приведу только относительные результаты измерений.
Simulator | Virtual slower, % |
---|---|
Riviera | 7 |
Questa | 64 |
VCS | 1 |
Xcelium | 10 |
Questa показывает весьма ощутимое замедление, тогда как остальные симуляторы не видят большой трагедии в виртуальных методах. VCS оказался так хорош, что даже подозрительно.
Как можно объяснить высокую эффективность? Рандомизировали объект мы только единожды, что упрощает задачу на аппаратном уровне. Код нужного метода может храниться в кэше, а предсказание условных переходов быстро выбирает этот метод. Это же может объяснить и проблемы Квесты: она работала через виртуализацию. Теоретически, при компиляции вызов виртуальной функции мог быть заменён на две инлайн (встроенных) функции с простым условным ветвлением. Более подробное исследование внутренностей симуляторов выходит за рамки этой статьи.
Конечно, возможность оптимизаций и их эффективность сильно зависят от контекста вызова виртуального метода. С другой стороны, в реальном тестбенче можно ожидать, что даже замедление вызова у Квесты потеряется на фоне времени работы всего тестбенча и дута.
То есть, бояться виртуальных методов из-за скорости работы однозначно не стоит, а оптимизировать нужно там где узко, и не раньше времени.
Заключение
- Виртуальный метод - это метод, реализация которого выбирается соответственно типу объекта, а не типу ссылки.
- Виртуальные методы позволяют добавлять и изменять функционал существующего кода, не вмешиваясь в него.
- Виртуальные методы не стоит использовать в конструкторе, в выражениях
wait()
и@()
, а также там, где есть зависимость от конкретной реализации метода. - Из виртуального метода можно вызвать его предыдущие реализации с помощью оператора
::
иsuper
. - На вызов виртуального метода требуется больше времени, чем на вызов невиртуального. Однако эта разница не столь велика, чтобы заботиться о ней раньше времени.
PDF версию статьи ищи здесь