Skip to content

Interfaces declare virtual ~I() = default, which links deleting destructors and operator delete/free into heap-free firmware #359

Closed

Description

@gabrielfrasantos

Problem

The toolbox's rule (AGENTS.md:47, CLAUDE.md, .github/copilot-instructions.md) is "interfaces virtual ~I() = default", and 21 classes follow it. Nothing in the library, or in its embedded users, destroys an object through an interface pointer, because the library is heap-free. A virtual destructor still makes the compiler:

  • emit a deleting destructor (D0) for every concrete class and put it in the vtable, so it is never garbage-collected;
  • call operator delete from that D0, which links newlib's free/malloc into firmware that never allocates;
  • under multiple inheritance, emit one non-virtual thunk per base, and at -O2 GCC inlines the whole destructor body into each thunk.

Measured (e-foc, EK-TM4C123GXL, sync_foc_sensored, RelWithDebInfo, GCC 13.2)

  • e-foc applied the same rule to its own ~40 interfaces. Switching them to protected non-virtual destructors cut 18.4 KB of flash (231,936 → 213,496 B): 13.3 KB of destructor thunks and 3.2 KB of deleting destructors went away.
  • The toolbox's own share in that firmware is small today: 96 B in 12 destructor functions (Lqr<float,2,1,300>, Lqr<float,3,1,300>, DeadbeatControl<float,1,1,1>/<…,1,1,2>, PidIncrementalSynchronous<float>, RecursiveLeastSquares<float,3>). But those are 6 of the 18 remaining callers of operator delete that keep malloc/free linked. The cost also grows with every toolbox class a user adopts, and much faster as soon as one is multiply inherited.

Proposal

Use the EMIL convention: a protected, non-virtual destructor on interfaces, and no destructor declaration on concrete classes:

class StateFeedbackController
{
protected:
    ~StateFeedbackController() = default;

public:
    virtual ... = 0;
};

This also turns accidental deletion through an interface into a compile error. Code that owns objects through a base pointer has to change too, e.g. numerical/analysis/windowing/test/TestWindowing.cpp, which holds std::unique_ptr<windowing::Window<T>>. It can hold the concrete type instead, or that one host-only interface can keep a virtual destructor as a documented exception.

Sites on main (c181cd1):

  • numerical/optimization/BayesianOptimization.hpp:55 (BlackBoxObjective)
  • numerical/optimization/Optimizer.hpp:15
  • numerical/optimization/ObjectiveFunction.hpp:18
  • numerical/optimization/StepOptimizer.hpp:13
  • numerical/controllers/interfaces/PidDriver.hpp:12
  • numerical/controllers/interfaces/PidController.hpp:29 (AsynchronousPidController)
  • numerical/controllers/interfaces/OutputFeedbackController.hpp:24
  • numerical/controllers/interfaces/StateFeedbackController.hpp:22
  • numerical/controllers/implementations/Feedforward2Dof.hpp:15 (Feedforward), :26 (FeedbackLaw)
  • numerical/nonlinear_control/FeedbackLinearization.hpp:21 (ControlAffineModel)
  • numerical/nonlinear_control/BacksteppingControl.hpp:21 (StrictFeedbackModel)
  • numerical/solvers/OdeSystem.hpp:15, :28
  • numerical/solvers/Solver.hpp:16
  • numerical/regularization/Regularization.hpp:17
  • numerical/estimators/Estimator.hpp:14 (OfflineEstimator), :32 (OnlineEstimator)
  • numerical/analysis/windowing/Windowing.hpp:17 (Window)
  • numerical/analysis/FastFourierTransform.hpp:12 (TwiddleFactors), :24

Update the rule in AGENTS.md, CLAUDE.md and .github/copilot-instructions.md along with the code.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions