一个测量任务可能保存总体值配置 OverallConfig,也可能保存频谱配置 SpectrumConfig。问题首先是如何表达“当前是哪一种配置”,其次才是如何节省内存。
原笔记把含 QString、QVector 的配置直接放进 union,又尝试在外层补一个析构函数。这里要分别处理:默认特殊成员是否被删除、哪个成员的生命周期已开始,以及标签是否与实际对象一致。本文限定 C++17;Qt 业务示例使用 Qt 6 Core。
非平凡成员可以进入 union,但不能忽略生命周期
跳转到“非平凡成员可以进入 union,但不能忽略生命周期”“非平凡”不是“代码复杂”的同义词,要说明具体是默认构造、复制、移动还是析构操作非平凡。普通 class 含 std::string 或 QString 成员,并不会因此自动丧失所有复制和析构能力。
union 的成员共享存储,同一时刻至多有一个活动成员。C++17 中,某成员的相应特殊操作非平凡时,union 的对应默认操作可能被删除;必须按具体操作的规则判断。以 std::string 为成员的 union 为例,不能直接依赖自动生成的默认构造和析构来管理它。C++17 union 规则
外层 MeasurementSetup、再外层 TaskTemplate 可能因包含不可析构的子对象而连带不可析构。只给最外层写一个析构函数,不会自动修复内部 union 已删除的析构函数。
原手工版本还存在更直接的错误:构造了 overall,却留下表示 spectrum 的标签,退出时对未活动的 spectrum 调用析构。这个操作是未定义行为;不能靠它观察到的某次运行结果推导正确性。
为互斥配置选择表示方式
跳转到“为互斥配置选择表示方式”| 表示方式 | 得到什么 | 必须另外处理什么 |
|---|---|---|
std::variant<monostate, OverallConfig, SpectrumConfig> | 类型标签与活动值由一个对象管理,monostate 表示尚未配置 | 合法业务组合、错误访问和可能的异常空值 |
两个 unique_ptr 加一个 bool | 自动释放各自拥有的对象,可以延后创建 | 两个指针可能同时非空或都为空,bool 也可能不一致;需要集中维护约束 |
| 手写带标签 union | 精确控制共享存储和对象寿命 | 构造、销毁、切换、异常、复制和移动的全部规则 |
两个智能指针的析构本来就会释放资源,无需在析构函数里根据 bool 再手动 reset()。额外声明析构函数还可能抑制隐式移动;含 unique_ptr 的类是否允许复制,也要明确设计。原 void* 版本还把不存在的命名类型写成析构函数,不能当作可用方案。特殊成员函数
variant 的替代值保存在自身存储内,不为替代值对象额外分配空间;这不代表它内部的 QString、容器永远不分配。复制能力取决于各替代类型。get_if<T> 不匹配时返回空指针;get<T> 不匹配会抛出 bad_variant_access。某些抛异常的类型切换还可能使 variant 进入 valueless_by_exception 状态,这与正常持有 monostate 不同。variant 的值和访问规则
原任务模板的完整字段不能在修复时丢掉
跳转到“原任务模板的完整字段不能在修复时丢掉”同一问题的另一份原稿给出了更完整的 fileparser.h。这些业务字段与 union 的存储问题是两层事情:修复生命周期时,应保留所需数据模型,再决定哪些类型互斥。原声明的字段如下。
| 类型 | 原字段及类型 |
|---|---|
FrequencyRange | double min = 0、double max = 0 |
OverallTypeItem | QString resourceId、QString type |
FrequencySpectrum | double maxFrequency = 0、QString maxType、double minFrequency = 0 |
AverageSettings | int number = 0、int overlapPercent = 0、double factor = 0、QString type |
TimeWaveformConfig | QString name、QString resourceId、QString quantity |
OverallConfig | QString name、QString quantity、int measurementTime = 0、QVector<OverallTypeItem> typeItems、FrequencyRange frequencyRange |
SpectrumConfig | QString name、QString resourceId、QString quantity、int lines = 0、QString windowFunction、TimeWaveformConfig timeWaveformConfig、AverageSettings averageSettings、FrequencySpectrum frequencySpectrum |
MeasurementSetup | QString kind,以及原来的 config:bool isOverall = false 和共享存储的 overall / spectrum |
TaskTemplate | QString version、QString taskId、QString name、QString source、MeasurementSetup measurementSetup |
原类还声明了公开静态入口 parseFromFile(const QString&),以及私有静态辅助函数 parseMeasurementSetup、parseSpectrumConfig、parseOverallConfig。这些只是解析职责的接口声明,原文没有给出可运行的文件解析实现;不能凭后面的省略号认定所有字段已经读取、校验或保存。
原建议同时写了 MeasurementSetup() = default、~MeasurementSetup() = default,又把 union 改成同时拥有两个配置的普通 struct。若普通 struct 能正常生成特殊成员,原因在于每个子对象都有自己的存储并按正常规则构造、销毁;这改变了“互斥共享存储”的表示。= default 本身不能让一个因子对象限制而被删除的函数重新可用,也不会替 union 选择活动成员。
TaskTemplate taskTemplate; 仍然要求这个类型可构造、可析构。将对象改为局部变量或交给 unique_ptr 所有,同样需要正确管理被拥有对象的生命周期;它们都不能绕过被删除的析构函数。应先修正配置类型,再实现文件读取和解析。
下方完整程序保留两种互斥配置及取列用途,但刻意使用较小的教学字段集;它没有实现此表的全部业务字段。扩展真实工程时,可把表中 OverallConfig、SpectrumConfig 的完整值类型放入 variant,并按实际文件格式逐项解析、验证和提交。不要把两段示例的同名结构体直接放进同一个翻译单元。
完整业务例子:先解析候选配置,再替换旧值
跳转到“完整业务例子:先解析候选配置,再替换旧值”下例保留 kind、总体值/频谱两种配置,以及按“名称、任务ID、来源、量值”取列值的用途。XML 只定义一个教学子集:无命名空间的 meas_setup 根节点有非空 kind,且恰好包含一个 overall 或 spectrum 子节点,子节点必须有非空 quant 属性。其他属性暂不校验;这不是厂商任务模板的完整规范。
解析器有 64 KiB 输入上限,拒绝 DTD、额外层级、非空文本、重复配置和 XML 错误。它始终检查读到结尾的状态,修复原循环在畸形输入下无法结束的问题。失败时原对象保持不变。
#include <QByteArray>#include <QString>#include <QVector>#include <QXmlStreamReader>#include <cassert>#include <type_traits>#include <utility>#include <variant>
struct OverallConfig { QString quantity; double value = 0.0; };struct SpectrumConfig { QString quantity; QVector<double> bins; };using Configuration = std::variant<std::monostate, OverallConfig, SpectrumConfig>;struct MeasurementSetup { QString kind; Configuration config; };struct TaskTemplate { QString name; QString taskId; QString source; MeasurementSetup measurementSetup;};
bool parseMeasurementSetup(const QByteArray& bytes, MeasurementSetup& output, QString& error) { error.clear(); if (bytes.size() > 64 * 1024) { error = "input too large"; return false; } MeasurementSetup candidate; QXmlStreamReader xml(bytes); int depth = 0; int configurations = 0; bool rootSeen = false; auto fail = [&](const QString& message) { error = message; return false; }; while (!xml.atEnd()) { xml.readNext(); if (xml.isDTD()) return fail("DTD is not allowed"); if (xml.isStartElement()) { ++depth; if (!xml.namespaceUri().isEmpty()) return fail("unexpected namespace"); if (depth == 1 && !rootSeen && xml.name() == u"meas_setup") { rootSeen = true; candidate.kind = xml.attributes().value(u"kind").toString(); if (candidate.kind.isEmpty()) return fail("missing kind"); } else if (depth == 2 && rootSeen && ++configurations == 1) { const QString quantity = xml.attributes().value(u"quant").toString(); if (quantity.isEmpty()) return fail("missing quant"); if (xml.name() == u"overall") candidate.config = OverallConfig{quantity, 0.0}; else if (xml.name() == u"spectrum") candidate.config = SpectrumConfig{quantity, {}}; else return fail("unknown configuration"); } else return fail("unexpected element or duplicate configuration"); } else if (xml.isEndElement()) { --depth; } else if (xml.isCharacters() && !xml.isWhitespace()) { return fail("unexpected text"); } } if (xml.hasError()) return fail(xml.errorString()); if (!rootSeen || configurations != 1 || depth != 0) return fail("missing configuration"); static_assert(std::is_nothrow_move_assignable_v<MeasurementSetup>); output = std::move(candidate); return true;}
QString getValueByColumn(const TaskTemplate& task, const QString& column) { if (column == QStringLiteral("名称")) return task.name; if (column == QStringLiteral("任务ID")) return task.taskId; if (column == QStringLiteral("来源")) return task.source; if (column != QStringLiteral("量值")) return {}; const auto& config = task.measurementSetup.config; if (config.valueless_by_exception()) return QStringLiteral("配置异常"); return std::visit([](const auto& value) -> QString { using T = std::decay_t<decltype(value)>; if constexpr (std::is_same_v<T, std::monostate>) return {}; else return value.quantity; }, config);}
int main() { TaskTemplate task{QStringLiteral("Balance"), QStringLiteral("task-001"), QStringLiteral("factory"), {}}; assert(std::holds_alternative<std::monostate>(task.measurementSetup.config)); assert(getValueByColumn(task, QStringLiteral("量值")).isEmpty()); QString error; assert(parseMeasurementSetup( R"(<meas_setup kind="overall"><overall quant="acc"/></meas_setup>)", task.measurementSetup, error)); assert(std::holds_alternative<OverallConfig>(task.measurementSetup.config)); assert(parseMeasurementSetup( R"(<meas_setup kind="trending_spectrum_amplitude"><spectrum quant="acc"/></meas_setup>)", task.measurementSetup, error)); auto* spectrum = std::get_if<SpectrumConfig>(&task.measurementSetup.config); assert(spectrum != nullptr); spectrum->bins = {1.0, 2.0}; assert(std::get_if<OverallConfig>(&task.measurementSetup.config) == nullptr); assert(getValueByColumn(task, QStringLiteral("名称")) == "Balance"); assert(getValueByColumn(task, QStringLiteral("任务ID")) == "task-001"); assert(getValueByColumn(task, QStringLiteral("来源")) == "factory"); assert(getValueByColumn(task, QStringLiteral("量值")) == "acc"); assert(getValueByColumn(task, QStringLiteral("未知列")).isEmpty());
TaskTemplate copied = task; std::get<SpectrumConfig>(copied.measurementSetup.config).bins.append(3.0); assert(spectrum->bins.size() == 2); assert(std::get<SpectrumConfig>(copied.measurementSetup.config).bins.size() == 3); bool wrongAccessRejected = false; try { (void)std::get<OverallConfig>(task.measurementSetup.config); } catch (const std::bad_variant_access&) { wrongAccessRejected = true; } assert(wrongAccessRejected);
const QVector<QByteArray> invalid{ "", "<meas_setup kind='x'>", "<meas_setup kind='x'/>", "<meas_setup><overall quant='acc'/></meas_setup>", "<meas_setup kind='x'><spectrum/></meas_setup>", "<meas_setup kind='x'><unknown quant='acc'/></meas_setup>", "<meas_setup kind='x'><overall quant='acc'/><spectrum quant='acc'/></meas_setup>", "<meas_setup xmlns='urn:wrong' kind='x'><overall quant='acc'/></meas_setup>", "<meas_setup kind='x'><overall quant='acc'>bad</overall></meas_setup>", "<!DOCTYPE meas_setup><meas_setup kind='x'><overall quant='acc'/></meas_setup>", QByteArray(64 * 1024 + 1, 'x') }; for (const auto& bytes : invalid) { assert(!parseMeasurementSetup(bytes, task.measurementSetup, error)); assert(!error.isEmpty()); assert(std::get<SpectrumConfig>(task.measurementSetup.config).bins.size() == 2); }}本例从配置的活动类型判断 overall/spectrum,不再额外保存一个容易失配的 bool。kind 仍是输入中的测量描述;它与配置种类的业务对应关系应由真实文件规范另外规定。完整任务模板的更多字段及命名空间处理见 Qt XML 任务模板。
把上限放在这里限制的是交给解析器的字节数组。读取磁盘文件时还应采用有上限的读取并处理 I/O 错误,不能先无限制读取再声称内存已被保护。QXmlStreamReader 的状态和错误接口见 Qt 官方文档。
手写 union 时必须把标签与对象一起管理
跳转到“手写 union 时必须把标签与对象一起管理”下例是独立的标准 C++17 教学程序,用字符串资源说明底层规则。Storage 有自己的构造与析构;外层负责活动成员。为避免默认复制存储字节造成错误,示例显式禁止复制和移动。
切换到字符串前,先销毁旧活动值;新构造完成后才设置 Resource 标签。注入的构造异常会让对象保持 Empty 状态,可继续使用和销毁,但不承诺保留原值。它与前面的“先解析候选对象再提交”采用不同的异常保证。
#include <cassert>#include <memory>#include <new>#include <stdexcept>#include <string>#include <type_traits>#include <utility>
struct Resource { inline static int live = 0; std::string text; Resource(std::string input, bool fail) : text(std::move(input)) { if (fail) throw std::runtime_error("requested construction failure"); ++live; } ~Resource() { --live; }};
class TaggedValue { union Storage { char empty; int number; Resource resource; Storage() noexcept : empty(0) {} ~Storage() {} // 活动 Resource 由 TaggedValue 析构 } storage_; enum class Kind { Empty, Number, Resource }; Kind kind_ = Kind::Empty;
public: TaggedValue() = default; ~TaggedValue() { reset(); } TaggedValue(const TaggedValue&) = delete; TaggedValue& operator=(const TaggedValue&) = delete; TaggedValue(TaggedValue&&) = delete; TaggedValue& operator=(TaggedValue&&) = delete;
void reset() noexcept { if (kind_ == Kind::Resource) storage_.resource.~Resource(); kind_ = Kind::Empty; storage_.empty = 0; } void setNumber(int value) { reset(); new (static_cast<void*>(std::addressof(storage_.number))) int(value); kind_ = Kind::Number; } void setResource(std::string text, bool fail = false) { reset(); new (static_cast<void*>(std::addressof(storage_.resource))) Resource(std::move(text), fail); kind_ = Kind::Resource; } bool empty() const noexcept { return kind_ == Kind::Empty; } int number() const { if (kind_ != Kind::Number) throw std::logic_error("not a number"); return storage_.number; } const std::string& text() const { if (kind_ != Kind::Resource) throw std::logic_error("not a resource"); return storage_.resource.text; }};
int main() { static_assert(!std::is_copy_constructible_v<TaggedValue>); static_assert(!std::is_move_constructible_v<TaggedValue>); assert(Resource::live == 0); { TaggedValue value; assert(value.empty()); value.setResource("spectrum"); assert(Resource::live == 1 && value.text() == "spectrum"); value.setNumber(42); assert(Resource::live == 0 && value.number() == 42); value.setResource("overall"); assert(Resource::live == 1); bool failed = false; try { value.setResource("replacement", true); } catch (const std::runtime_error&) { failed = true; } assert(failed && value.empty() && Resource::live == 0); value.reset(); value.setResource("ready again"); assert(Resource::live == 1 && value.text() == "ready again"); } assert(Resource::live == 0);}空的 Storage 析构函数只让存储层可析构,并没有自动释放字符串;真正的销毁来自外层按标签调用的析构。定位构造也只在已经具备足够大小和对齐的存储中开始对象寿命。若以后允许复制或移动,必须逐种活动类型构造目标并维护异常路径,不能简单复制整个 union 的字节。对象生命周期规则
对于这里的测量配置,variant 能减少需要手工维持的状态。低层 union 程序用于理解原报错和修复边界,不必把它扩展成业务层自己的 variant 库。