Захотелось мне сделать telnet сервер для управления разной техникой на популярном и недорогом модуле WIZnet W5500. Все, что для этого нужно, входит в состав стандартной библиотеки Arduino, результат можно посмотреть тут. Но речь не о нем. Первое, что меня сильно удивило, — этот простой по функциональности код занял больше половины флэша ATmega328P. Конечно, кода в библиотеке Ethernet много, но ведь он не весь используется, компилятор должен выбрасывать неиспользуемый код из собранной прошивки. Проверим, так ли это.
Заходим в директорию, где происходит сборка, — путь к ней можно подсмотреть в сообщениях компиляции, и делаем objdump -t <elf файл прошивки>, чтобы получить таблицу символов. Видим в ней множество связанных с Ethernet функций, включая такие, надобность которых не очевидна, например функции для работы с UDP. То есть выглядит все так, как будто удаления ненужных функций не произошло. В чем же дело?
Ответ может показаться неожиданным, — все дело в наследовании классов, реализующих Ethernet, от базовых классов с множеством виртуальных функций. Компилятор считает, что функция (или метод класса) используется, когда на нее есть ссылки в других местах кода. Но для того, чтобы создать такую ссылку, функцию необязательно вызывать. Достаточно сохранить ее адрес. Даже если мы не делаем этого явно, C++ это делает за нас, помещая указатель на функцию в таблицу виртуальных функций. Даже если мы никогда не пользуемся этой виртуальной функцией, она будет присутствовать в прошивке. Если функция определена в базовом классе как чисто-виртуальная (без реализации), то у нас нет других вариантов, кроме как реализовать ее, даже если она нам вообще не нужна, тем самым увеличив размер кода прошивки.
Проверим, правильность нашей гипотезы. Возьмем библиотеку Ethernet из гитхаба, например здесь, чтобы не трогать стандартную, и модифицируем ее. Уберем наследование, а виртуальные функции сделаем просто методами. Как это сделать аккуратно, обратимым способом, можно посмотреть тут. Результат: размер кода уменьшился на 4460 байт — более чем на четверть от первоначального размера.
Конечно, наследование и виртуальные функции бывают полезны. Однако создавать базовый класс с чистыми виртуальными функциями только для того, чтобы определить интерфейс для последующих реализаций, не всегда оправдано. Сначала стоит убедиться, что вы действительно будете пользоваться этим интерфейсом с объектами разных типов, либо функциональность, реализованная в базовом классе (как например в классе Print), будет вам полезна.
fougasse
Не очень понятно почему неиспользуемые функции не могут быть оптимизированы даже если они виртуальные.
gcc "девиртуализирует" функции на -Os/O2
sergegers
Во первых, девиртуализация — это не выбрасывание неиспользуемых методов, а замена виртуального вызова на невиртуальный.
Сейчас девиртуализация в реальном проекте делается компилятором из большой тройки на должном уровне оптимизации, если граф вызовов не очень сложный, в реальном проекте это если создание объекта и вызов метода находятся в той же функции или во вложенной на один-два уровня. Кроме того, невозможно девиртуализировать вызовы объектов, которые приходят в компонент извне.
А чтобы повыбрасывать неиспользуемый код, надо глобально девиртулизовать все виртуальные вызовы, и уже у тех иерархий, у которых это удалось, удалять неиспользуемые методы. На сегодняшний день компиляторы могут делать такое только в тривиальных случаях.
fougasse
Ну так «с дуру можно и орган сломать», если у вас в прошивке для МК виртуальные функции в интерфейсе компонентов — вы понимаете и принимаете все риски относительно, как минимум, размера бинарника.
Внутри современные компиляторы типа clang вполне способны на подобные оптимизации для довольно сложных случаев.
Что не отменяет факта, что можно нагородить такого, что компилятор с оптимизатором не смогут ничего сделать.
sergegers
Выражусь предельно ясно:
— девиртуализация делается для локальных переменных;
— удаления неиспользуемых виртуальных функций не происходит.
Ryppka
Не совсем понял, почему тут зашла речь про девиртуализацию вызовов, уровни оптимизации и компилятор вообще. Компилятор создает объектный файл. А что из объектного файла попадает или не попадает в бинарный образ — прерогатива компоновщика.