From f3c175ab39288beb7b95d1c2c0e1735c1a16edd0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 28 Feb 2026 16:29:19 -1000 Subject: [PATCH 1/2] [api] Remove virtual destructor from ProtoMessage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API protobuf messages are never deleted through base class pointers — they are always stack-allocated and passed by reference. The virtual destructor occupies 2 vtable slots (complete + deleting destructor) on 32-bit platforms using the Itanium ABI, costing 8 bytes per vtable. With 122 message classes, removing the virtual destructor saves ~976 bytes of flash. Each vtable shrinks from 24 bytes to 16 bytes. --- esphome/components/api/api_pb2.h | 3 --- esphome/components/api/proto.h | 3 ++- script/api_protobuf/api_protobuf.py | 3 +-- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 22dc3de995d..2d4bdfbaece 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -316,7 +316,6 @@ enum ZWaveProxyRequestType : uint32_t { class InfoResponseProtoMessage : public ProtoMessage { public: - ~InfoResponseProtoMessage() override = default; StringRef object_id{}; uint32_t key{0}; StringRef name{}; @@ -334,7 +333,6 @@ class InfoResponseProtoMessage : public ProtoMessage { class StateResponseProtoMessage : public ProtoMessage { public: - ~StateResponseProtoMessage() override = default; uint32_t key{0}; #ifdef USE_DEVICES uint32_t device_id{0}; @@ -345,7 +343,6 @@ class StateResponseProtoMessage : public ProtoMessage { class CommandProtoMessage : public ProtoDecodableMessage { public: - ~CommandProtoMessage() override = default; uint32_t key{0}; #ifdef USE_DEVICES uint32_t device_id{0}; diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index c34f7744e6b..ac6e707ee75 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -452,7 +452,8 @@ class DumpBuffer { class ProtoMessage { public: - virtual ~ProtoMessage() = default; + // No virtual destructor - messages are never deleted polymorphically + // (all are stack-allocated and used through references) // Default implementation for messages with no fields virtual void encode(ProtoWriteBuffer &buffer) const {} // Default implementation for messages with no fields diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 350947a8d69..081d5d23a88 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -2473,8 +2473,7 @@ def build_base_class( out = f"class {base_class_name} : public {parent_class} {{\n" out += " public:\n" - # Add destructor with override - public_content.insert(0, f"~{base_class_name}() override = default;") + # No virtual destructor - messages are never deleted polymorphically # Base classes don't implement encode/decode/calculate_size # Derived classes handle these with their specific field numbers From 28178b8c1ccaca987f6bca458ed1f7b51a07f4ae Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 28 Feb 2026 16:39:13 -1000 Subject: [PATCH 2/2] fixes --- esphome/components/api/api_pb2.h | 3 +++ esphome/components/api/proto.h | 8 ++++++-- script/api_protobuf/api_protobuf.py | 4 ++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 2d4bdfbaece..cfd3e075439 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -329,6 +329,7 @@ class InfoResponseProtoMessage : public ProtoMessage { #endif protected: + ~InfoResponseProtoMessage() = default; }; class StateResponseProtoMessage : public ProtoMessage { @@ -339,6 +340,7 @@ class StateResponseProtoMessage : public ProtoMessage { #endif protected: + ~StateResponseProtoMessage() = default; }; class CommandProtoMessage : public ProtoDecodableMessage { @@ -349,6 +351,7 @@ class CommandProtoMessage : public ProtoDecodableMessage { #endif protected: + ~CommandProtoMessage() = default; }; class HelloRequest final : public ProtoDecodableMessage { public: diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index ac6e707ee75..750fff08102 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -452,8 +452,6 @@ class DumpBuffer { class ProtoMessage { public: - // No virtual destructor - messages are never deleted polymorphically - // (all are stack-allocated and used through references) // Default implementation for messages with no fields virtual void encode(ProtoWriteBuffer &buffer) const {} // Default implementation for messages with no fields @@ -464,6 +462,11 @@ class ProtoMessage { virtual const char *dump_to(DumpBuffer &out) const = 0; virtual const char *message_name() const { return "unknown"; } #endif + + protected: + // Non-virtual: messages are never deleted polymorphically. + // Protected prevents accidental `delete base_ptr` (compile error). + ~ProtoMessage() = default; }; // Base class for messages that support decoding @@ -483,6 +486,7 @@ class ProtoDecodableMessage : public ProtoMessage { static uint32_t count_repeated_field(const uint8_t *buffer, size_t length, uint32_t target_field_id); protected: + ~ProtoDecodableMessage() = default; virtual bool decode_varint(uint32_t field_id, ProtoVarInt value) { return false; } virtual bool decode_length(uint32_t field_id, ProtoLengthDelimited value) { return false; } virtual bool decode_32bit(uint32_t field_id, Proto32Bit value) { return false; } diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 081d5d23a88..9c9cda4d36e 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -2473,8 +2473,6 @@ def build_base_class( out = f"class {base_class_name} : public {parent_class} {{\n" out += " public:\n" - # No virtual destructor - messages are never deleted polymorphically - # Base classes don't implement encode/decode/calculate_size # Derived classes handle these with their specific field numbers cpp = "" @@ -2482,6 +2480,8 @@ def build_base_class( out += indent("\n".join(public_content)) + "\n" out += "\n" out += " protected:\n" + # Non-virtual protected destructor prevents accidental polymorphic deletion + protected_content.insert(0, f"~{base_class_name}() = default;") out += indent("\n".join(protected_content)) if protected_content: out += "\n"