C++面向对象设计实战:基于策略模式与状态机的字符串数字提取器

C++面向对象设计实战:基于策略模式与状态机的字符串数字提取器
1. 项目概述从字符串里“捞”数字一个看似简单却暗藏玄机的实战最近在带新人做项目时发现一个挺有意思的现象很多刚接触C的朋友一提到“从字符串里提取数字”第一反应就是写个循环然后isdigit判断再stoi转换。这思路没错但一旦放到稍微复杂点的场景里比如“abc123def456.78ghi-90”要完整、准确地提取出所有整数、浮点数甚至考虑正负号、科学计数法代码立刻就变得臃肿且脆弱。这恰恰是检验我们面向对象OOP设计能力的一个绝佳小项目。它不像造火箭那么宏大但能把封装、继承、多态这些OOP核心思想在一个具体、可感知的问题上练得明明白白。这个“数字串中查找数字”的项目核心目标就是设计一个健壮、可扩展的类或类体系它能智能地扫描任意给定的字符串精准地识别并提取出其中所有符合规范的数字片段无论是123、-45.67还是1.23e4。你别看它小里面涉及的状态管理、错误恢复、接口设计都是实打实的工程问题。我自己在早期开发日志解析、数据清洗工具时就反复重构过类似的模块踩过的坑不少但也总结出了一套比较实用的设计模式。今天我就把自己这套经过实战检验的思路和实现细节掰开揉碎了分享给你。无论你是想巩固C OOP基础还是需要为一个实际的数据预处理模块寻找灵感这篇文章都能给你直接的参考。2. 核心需求与设计思路拆解2.1 需求场景深度分析首先我们得把“查找数字”这个模糊的需求具体化。在一个真实的工程环境里输入字符串可能千奇百怪。我们至少要考虑以下几类情况整数正数123、负数-456、带前导零的数007通常我们按7处理。浮点数标准小数3.14、-0.618、科学计数法1.23e4、5.6E-7。这里要注意小数点前或后可以没有数字如.5或5.在某些格式下是合法的科学计数法的e/E后面可以跟正负号。混合字符串数字和非数字字符交错出现如价格: $199.99, 重量: 12.5kg。边界与错误连续的小数点3..14、非法的科学计数法1.2e后无数字、字符串中间的空格或制表符通常视为分隔符而非数字的一部分。如果只用过程式的if-else和状态变量来硬编码代码会迅速膨胀成一个难以维护的“面条式”状态机。这正是OOP登场的时候。我们的设计目标很明确将数字识别的复杂逻辑封装起来对外提供一个简洁、稳定的接口同时设计要足够灵活以便未来轻松支持新的数字格式比如十六进制0x1A。2.2 面向对象设计策略选择面对这个问题我实践下来最有效的是“策略模式Strategy Pattern”与“有限状态机Finite State Machine, FSM”的结合体。别被名字吓到其实思想很直观。核心类NumberScanner这是我们的门面Facade。它不关心具体的识别算法只负责管理输入字符串、协调不同的“识别器”、并收集结果。它的主要职责是接收输入字符串。按顺序遍历字符串的每个字符。将当前字符和上下文比如前一个字符是什么当前是否正在识别一个数字交给一个“状态机”去判断。根据状态机的反馈决定是开始记录一个新数字、继续记录、还是结束记录并保存结果。抽象基类NumberParser这就是“策略”接口。它定义了一个数字解析器必须实现的方法比如bool canParse(char currentChar, ParserContext context)和void parse(char currentChar, ParserContext context)。这样NumberScanner只需要调用接口而不需要知道背后是整数解析器还是浮点数解析器在工作。具体解析器IntegerParser,FloatParser它们继承自NumberParser各自实现一套完整的FSM。例如IntegerParser的状态可能包括START、IN_SIGN、IN_INTEGER。FloatParser的状态则更复杂START、IN_SIGN、BEFORE_DOT、AFTER_DOT、IN_EXP_SIGN、IN_EXPONENT等。注意这里有一个关键设计决策——是否让FloatParser也识别整数理论上可以但为了职责清晰和避免歧义我通常让IntegerParser优先。如果一个数字片段能被解释为整数就不动用更复杂的浮点数解析器。这可以通过调整解析器的优先级或扫描顺序来实现。上下文ParserContext这是一个轻量级的结构体或类用于在NumberScanner和各个NumberParser之间传递信息。它可能包含当前累积的数字字符串、上一个字符、当前解析状态、以及一个输出列表的引用。这样做避免了函数参数列表过长也使得状态传递更清晰。这种设计的最大好处是“开闭原则”当未来需要支持0x1A3F这样的十六进制数时你只需要新增一个HexParser类实现NumberParser接口然后将其注册到NumberScanner的解析器列表中即可。原有的IntegerParser和FloatParser代码一行都不用改。3. 核心类实现与关键代码解析理论说再多不如一行代码来得实在。下面我带你一步步实现最核心的几个类并解释关键代码段的意图和注意事项。3.1 定义数字类型与解析器接口首先我们定义一个枚举来标识提取出的数字类型并设计解析器的抽象基类。// 数字类型枚举 enum class NumberType { INTEGER, // 整数如 123, -456 FLOAT // 浮点数如 3.14, -2.5e-3 }; // 数字结构体存储最终结果 struct NumberToken { std::string raw; // 原始的字符串片段如 -123.45e6 NumberType type; // 数字类型 size_t position; // 在原始字符串中的起始索引 // 可以添加 value (long long, double) 字段用于存储转换后的数值 }; // 解析上下文传递扫描状态 struct ParserContext { std::string_view input; // 整个输入字符串的视图 size_t currentIndex; // 当前扫描到的索引 std::string buffer; // 临时缓存当前正在识别的数字字符 bool isParsingNumber; // 当前是否正在识别一个数字 std::vectorNumberToken results; // 结果输出列表 // 可以添加更多状态如上一个字符等 }; // 解析器抽象基类策略接口 class NumberParser { public: virtual ~NumberParser() default; // 判断当前字符是否可以被此解析器处理进入或继续解析 virtual bool canHandle(char currentChar, const ParserContext ctx) const 0; // 处理当前字符更新上下文如将字符加入buffer或完成一个数字的识别 virtual void handleChar(char currentChar, ParserContext ctx) 0; // 当扫描器认为一个数字识别结束时调用此方法进行最终处理如类型确认 virtual void finalize(ParserContext ctx) 0; // 解析器优先级数值越小优先级越高。用于解决重叠匹配问题如整数是浮点数的子集。 virtual int getPriority() const { return 10; } };3.2 实现整数解析器IntegerParser整数解析器相对简单它的状态机可以内嵌在canHandle和handleChar的逻辑中。class IntegerParser : public NumberParser { public: bool canHandle(char currentChar, const ParserContext ctx) const override { // 如果还没开始解析数字 if (!ctx.isParsingNumber) { // 可以以 、- 或数字开头 return (currentChar || currentChar - || isdigit(currentChar)); } else { // 如果已经在解析中则后续只能是数字 return isdigit(currentChar); } } void handleChar(char currentChar, ParserContext ctx) override { // 如果是正负号且buffer为空即数字刚开始则允许加入 if ((currentChar || currentChar -) ctx.buffer.empty()) { ctx.buffer.push_back(currentChar); } else if (isdigit(currentChar)) { ctx.buffer.push_back(currentChar); } // 注意这里没有处理其他字符扫描器遇到其他字符时会调用finalize } void finalize(ParserContext ctx) override { if (!ctx.buffer.empty()) { // 检查buffer是否是一个有效的整数不能只有符号 bool isValid false; for (char c : ctx.buffer) { if (isdigit(c)) { isValid true; break; } } if (isValid) { ctx.results.push_back({ ctx.buffer, NumberType::INTEGER, ctx.currentIndex - ctx.buffer.length() // 计算起始位置 }); } ctx.buffer.clear(); } } int getPriority() const override { return 1; } // 整数解析器优先级最高 };实操心得在finalize里检查“不能只有符号”非常关键。否则像ab这样的字符串会在位置错误地生成一个数字令牌。这种边界条件的处理是代码健壮性的体现。3.3 实现浮点数解析器FloatParser浮点数解析器是重头戏我们显式地使用一个状态枚举来管理逻辑会更清晰。class FloatParser : public NumberParser { enum class State { START, GOT_SIGN, // 已获取符号 (/-) GOT_INTEGER_PART, // 已获取小数点前的整数部分 GOT_DOT, // 已获取小数点 GOT_FRACTION_PART,// 已获取小数部分 GOT_E, // 已获取指数标志 (e/E) GOT_EXP_SIGN, // 已获取指数符号 (/-) GOT_EXPONENT // 已获取指数数字 } currentState State::START; public: bool canHandle(char currentChar, const ParserContext ctx) const override { // 浮点数解析器更“贪婪”它允许的起始字符更多样 if (!ctx.isParsingNumber) { // 可以以 符号、数字、小数点 开头如 .5 return (currentChar || currentChar - || isdigit(currentChar) || currentChar .); } // 如果已经在解析中则根据当前状态判断是否接受此字符 // 这里简化处理在实际handleChar中会做精确的状态转移判断 return true; } void handleChar(char currentChar, ParserContext ctx) override { switch (currentState) { case State::START: if (currentChar || currentChar -) { ctx.buffer.push_back(currentChar); currentState State::GOT_SIGN; } else if (isdigit(currentChar)) { ctx.buffer.push_back(currentChar); currentState State::GOT_INTEGER_PART; } else if (currentChar .) { ctx.buffer.push_back(currentChar); currentState State::GOT_DOT; } break; case State::GOT_SIGN: if (isdigit(currentChar)) { ctx.buffer.push_back(currentChar); currentState State::GOT_INTEGER_PART; } else if (currentChar .) { ctx.buffer.push_back(currentChar); currentState State::GOT_DOT; } else { // 无效状态如 a应回退。实际实现需更复杂的状态回滚。 currentState State::START; ctx.buffer.clear(); } break; case State::GOT_INTEGER_PART: if (isdigit(currentChar)) { ctx.buffer.push_back(currentChar); } else if (currentChar .) { ctx.buffer.push_back(currentChar); currentState State::GOT_DOT; } else if (currentChar e || currentChar E) { ctx.buffer.push_back(currentChar); currentState State::GOT_E; } else { // 数字部分结束触发最终化由扫描器调用finalize } break; // ... 其他状态GOT_DOT, GOT_FRACTION_PART, GOT_E, GOT_EXP_SIGN, GOT_EXPONENT类似处理 // 需要仔细处理 e/E 后面必须接数字或符号的逻辑 default: break; } // 简化示例完整实现需要处理所有状态转移和非法输入。 } void finalize(ParserContext ctx) override { // 最终检查buffer是否构成一个有效的浮点数 // 例如不能只有一个点.不能是123e指数部分必须有数字等。 // 这里可以调用 std::strtod 或类似方法进行验证或根据最终状态判断。 bool isValid validateFloatString(ctx.buffer); if (isValid) { ctx.results.push_back({ ctx.buffer, NumberType::FLOAT, ctx.currentIndex - ctx.buffer.length() }); } ctx.buffer.clear(); currentState State::START; // 重置状态机 } int getPriority() const override { return 2; } // 优先级低于整数解析器 private: bool validateFloatString(const std::string str) { // 简化验证使用标准库转换看是否成功 char* endPtr; std::strtod(str.c_str(), endPtr); return endPtr ! str.c_str() *endPtr \0; // 整个字符串被成功转换 } };踩坑记录浮点数状态机最易出错的地方在科学计数法部分。一定要确保在GOT_E状态后只接受符号或数字。如果接收到其他字符整个当前数字的识别应该失败并回滚。最初我忽略了这点导致1.2e3x错误地提取了1.2e3而x被当成了下一个字符。实际上e3后面紧跟非数字整个1.2e3在某些严格场景下也应视为无效。是否需要这么严格取决于你的业务需求。3.4 实现核心扫描器NumberScanner扫描器是大脑它按顺序使用注册的解析器。class NumberScanner { private: std::vectorstd::unique_ptrNumberParser parsers; // 按优先级排序解析器 void sortParsersByPriority() { std::sort(parsers.begin(), parsers.end(), [](const std::unique_ptrNumberParser a, const std::unique_ptrNumberParser b) { return a-getPriority() b-getPriority(); }); } public: NumberScanner() { // 注册默认解析器 registerParser(std::make_uniqueIntegerParser()); registerParser(std::make_uniqueFloatParser()); sortParsersByPriority(); } void registerParser(std::unique_ptrNumberParser parser) { parsers.push_back(std::move(parser)); sortParsersByPriority(); // 每次注册后重新排序 } std::vectorNumberToken scan(const std::string input) { std::vectorNumberToken results; ParserContext ctx{input, 0, , false, results}; NumberParser* activeParser nullptr; // 当前活跃的解析器 for (ctx.currentIndex 0; ctx.currentIndex input.length(); ctx.currentIndex) { char currentChar input[ctx.currentIndex]; if (!ctx.isParsingNumber) { // 寻找一个可以开始解析的解析器按优先级 activeParser nullptr; for (const auto parser : parsers) { if (parser-canHandle(currentChar, ctx)) { activeParser parser.get(); ctx.isParsingNumber true; ctx.buffer.clear(); break; // 找到第一个优先级最高的即可 } } if (activeParser) { activeParser-handleChar(currentChar, ctx); } } else { // 正在解析中检查当前活跃解析器是否能继续处理 if (activeParser activeParser-canHandle(currentChar, ctx)) { activeParser-handleChar(currentChar, ctx); } else { // 当前字符不能继续被处理结束当前数字的解析 if (activeParser) { activeParser-finalize(ctx); } ctx.isParsingNumber false; activeParser nullptr; // 重要当前字符可能是一个新数字的开始需要重新判断 // 因此将索引回退一步让循环下一次处理同一个字符 --ctx.currentIndex; } } } // 循环结束如果还在解析中需要最终化 if (ctx.isParsingNumber activeParser) { activeParser-finalize(ctx); } return results; } };核心技巧注意扫描器主循环中--ctx.currentIndex这一行。当活跃解析器无法处理当前字符时意味着一个数字识别结束。但当前字符可能正是下一个数字的开始比如从123切换到456中间的逗号,。我们回退索引让下一次循环重新用所有解析器去判断这个字符从而确保不会漏掉紧接着的数字。这是实现“贪婪”但正确识别的一个关键技巧。4. 使用示例与高级功能扩展4.1 基础使用与测试有了上面的类使用起来就非常直观了。#include iostream #include iomanip int main() { NumberScanner scanner; std::string testStr 测试字符串收入为1234.5元支出-98.76温度25.3e-1摄氏度。; auto tokens scanner.scan(testStr); std::cout 在字符串 \ testStr \ 中找到 tokens.size() 个数字\n; for (const auto token : tokens) { std::cout 位置[ std::setw(2) token.position ]: std::setw(12) token.raw (类型: (token.type NumberType::INTEGER ? 整数 : 浮点数) )\n; } return 0; }预期输出应该能识别出1234.5、-98.76和25.3e-1。4.2 性能优化考量上面的实现为了清晰牺牲了一些性能。在需要处理海量文本如日志流时可以考虑以下优化避免字符串拷贝ParserContext::buffer存储了临时字符串。对于超长数字虽然罕见可以改为存储起始索引和长度仅在finalize时构造子字符串。解析器选择优化canHandle被频繁调用。可以预先为每个解析器建立一个快速查找表基于字符的ASCII码第一轮快速过滤掉绝对不可能处理该字符的解析器。状态机内联将状态机的判断逻辑内联到扫描循环中消除虚函数调用和解析器遍历的开销。但这会牺牲代码的可扩展性和清晰度属于一种“空间换时间”的权衡只有在性能瓶颈确实在此处时才建议使用。4.3 功能扩展添加十六进制解析器展示OOP设计的扩展性有多么方便。假设我们现在需要支持C语言风格的十六进制数字如0x1A3F、0Xff。class HexParser : public NumberParser { enum class State { START, GOT_ZERO, GOT_X, IN_HEX } currentState State::START; public: bool canHandle(char currentChar, const ParserContext ctx) const override { // 简化判断只有不在解析中且当前字符是0时才有可能开始因为十六进制以0x开头 if (!ctx.isParsingNumber) { return currentChar 0; } // 解析中则根据状态判断此处简化 return true; } void handleChar(char currentChar, ParserContext ctx) override { currentChar std::tolower(currentChar); // 统一转小写方便处理 switch (currentState) { case State::START: if (currentChar 0) { ctx.buffer.push_back(currentChar); currentState State::GOT_ZERO; } break; case State::GOT_ZERO: if (currentChar x) { ctx.buffer.push_back(currentChar); currentState State::GOT_X; } else { // 不是x说明这不是十六进制应回退。这里需要更复杂的回滚逻辑。 // 简单示例中我们直接失败。 currentState State::START; ctx.buffer.clear(); } break; case State::GOT_X: if (isxdigit(currentChar)) { // 标准库函数判断是否为十六进制数字 ctx.buffer.push_back(currentChar); currentState State::IN_HEX; } else { // 0x后不是十六进制数字无效。 currentState State::START; ctx.buffer.clear(); } break; case State::IN_HEX: if (isxdigit(currentChar)) { ctx.buffer.push_back(currentChar); } else { // 非十六进制字符结束识别 } break; } } void finalize(ParserContext ctx) override { // 验证必须至少是 0x 一个十六进制数字 的格式 if (ctx.buffer.length() 3 ctx.buffer[0] 0 std::tolower(ctx.buffer[1]) x std::all_of(ctx.buffer.begin()2, ctx.buffer.end(), [](char c){ return isxdigit(c); })) { ctx.results.push_back({ ctx.buffer, NumberType::INTEGER, // 我们可以定义一个新的HEX类型这里简化为整数 ctx.currentIndex - ctx.buffer.length() }); } ctx.buffer.clear(); currentState State::START; } int getPriority() const override { return 0; } // 十六进制优先级最高因为它的模式最特殊 }; // 在主函数中使用 int main() { NumberScanner scanner; scanner.registerParser(std::make_uniqueHexParser()); // 注册新的解析器 // ... 注意需要重新排序或者在NumberScanner构造函数/registerParser中确保排序 auto tokens scanner.scan(内存地址: 0xCAFEBABE, 值: 123.456); // 将能识别出 0xCAFEBABE 和 123.456 }你看我们几乎没有改动原有的NumberScanner核心逻辑只是新增了一个类并注册进去功能就扩展了。这就是面向对象设计模式带来的强大可维护性。5. 常见问题、调试技巧与实战心得5.1 典型问题排查清单在实际编码和调试中你可能会遇到下面这些问题。这里我列一个速查表并附上我的排查思路问题现象可能原因排查与解决思路漏掉了紧挨着的数字如123abc456只找到123扫描器在结束一个数字后索引前进逻辑有误跳过了下一个数字的起始字符。检查扫描器主循环在activeParser无法处理当前字符时是否对ctx.currentIndex进行了正确的回退--ctx.currentIndex。错误地将非数字识别为数字的一部分如1.2.3被识别为1.2浮点数解析器状态机对小数点.的处理不严格允许了连续多个小数点。检查FloatParser的状态机在GOT_DOT或GOT_FRACTION_PART状态接收到.时应触发当前数字识别结束finalize而不是继续接受。科学计数法识别错误如1e被识别为有效数字finalize函数中缺少对科学计数法格式的最终验证。指数部分e/E后必须有数字。在FloatParser::finalize或validateFloatString中增加对科学计数法格式的严格校验确保e/E后面跟着有效的指数数字。性能低下处理长字符串慢1. 频繁的字符串拼接buffer.push_back。2. 对每个字符遍历所有解析器的canHandle。1. 考虑用string_view记录范围延迟构建字符串。2. 为解析器实现快速预过滤或根据首字符直接分派到少数可能解析器。内存地址0x开头的数字被错误拆分整数解析器先于十六进制解析器匹配了0。确保解析器按优先级排序且十六进制解析器(HexParser)的优先级最高getPriority返回值最小因为它匹配的模式最具体。5.2 调试与单元测试建议对于这种状态机复杂的代码光靠眼睛看和简单打印是不够的。可视化状态流在FloatParser::handleChar中每个状态转换处打印日志。例如std::cout [FloatParser] Char: currentChar , State: static_castint(currentState) - static_castint(newState) , Buffer: \ ctx.buffer \\n;这能帮你清晰地跟踪对于输入12.3e4状态是如何一步步变迁的。编写单元测试使用类似Google Test的框架针对各种边界情况编写测试用例。这是保证代码健壮性的不二法门。TEST(NumberScannerTest, MixedString) { NumberScanner scanner; auto tokens scanner.scan(a1.2b-3.4e5c); ASSERT_EQ(tokens.size(), 2); EXPECT_EQ(tokens[0].raw, 1.2); EXPECT_EQ(tokens[0].type, NumberType::FLOAT); EXPECT_EQ(tokens[1].raw, -3.4e5); EXPECT_EQ(tokens[1].type, NumberType::FLOAT); } TEST(NumberScannerTest, InvalidScientific) { NumberScanner scanner; auto tokens scanner.scan(1.2e); EXPECT_TRUE(tokens.empty()); // 应该识别失败不产生令牌 }使用调试器观察在finalize被调用前设置断点观察ctx.buffer的内容确认是否与你预期的数字片段一致。5.3 个人实战心得与进阶思考最后分享几点我踩过坑后才深刻理解的体会“贪心”与“最长匹配”的权衡我们这个扫描器本质上是“贪心”的会尽可能匹配最长的合法数字序列。这在大多数情况下是对的。但有些场景下你可能需要“最短匹配”或特定模式。那时可能需要调整算法或者在canHandle逻辑中加入更复杂的上下文判断。错误恢复策略当前实现中一旦一个解析器开始工作直到它主动失败或扫描器叫停它都占据主导。更健壮的实现需要考虑“错误恢复”。比如FloatParser在GOT_E状态后收到了一个字母x它应该能意识到这不是一个有效的浮点数并完全回滚到开始解析前的状态让扫描器尝试其他解析器比如HexParser可能会对0x...感兴趣。这需要解析器和扫描器之间有更复杂的协商机制。Unicode与本地化我们这个实现只处理了ASCII字符。在真实的多语言环境中数字字符可能不止0-9比如全角数字。这时isdigit就不够用了需要考虑使用locale或ICU库进行字符分类。这也是面向对象设计的好处你只需要修改或替换底层字符判断的函数上层解析逻辑可以保持不变。从“识别”到“提取”再到“转换”本项目聚焦于“识别”和“提取”出数字字符串。在实际应用中下一步往往是将其转换为int或double。你可以很容易地在NumberToken结构体中增加一个std::variantlong long, double字段并在finalize中调用std::stoll或std::stod进行转换和存储。但务必注意异常处理转换失败的数字令牌应该被丢弃或标记为无效。把这个项目吃透你收获的绝不仅仅是一个“字符串提取数字”的函数。你真正练手的是如何用C的面向对象特性去设计一个解耦、灵活、可维护的模块。下次当你面对更复杂的文本解析任务比如简单的JSON解析器、日志格式解析时这套设计模式和经验就是你信手拈来的工具箱。

最新新闻

日新闻

周新闻

月新闻