ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

C++面向对象设计实战:从零构建可扩展的牌组绘制系统

C++面向对象设计实战:从零构建可扩展的牌组绘制系统 在实际 C 项目中开发者常常会遇到一些看似简单、实则充满“陷阱”的编码任务比如处理一个需要特定绘制逻辑的“Painted Deck”可理解为绘制一副牌或一个装饰性面板。这类任务不仅考验对 C 基础语法和标准库的掌握更考验对程序结构、资源管理和绘制逻辑的理解。许多新手在尝试时代码很快就会变得混乱不堪难以维护最终陷入“希望这段疯狂赶紧结束”的境地。本文将围绕一个具体的“绘制牌组”案例带你从零构建一个结构清晰、可扩展的 C 程序。我们将使用现代 CC17/20的特性避免裸指针和手动内存管理的陷阱并融入面向对象设计最终实现一个可在控制台或简单图形界面如 Qt 控制台绘图中可视化的牌组系统。通过本文你将学会如何将模糊的需求分解为类、如何设计绘制接口、如何处理状态以及如何编写易于测试和调试的 C 代码。1. 理解需求与设计核心类在开始编码前必须明确我们要构建什么。一个“Painted Deck”可以抽象为一个由多张“牌”Card组成的集合Deck每张牌有花色Suit和点数Rank并且需要能够以某种形式“绘制”出来。在控制台环境中绘制通常意味着用字符组合成图形在图形界面中则可能涉及更复杂的绘图 API。1.1 定义枚举与基础类型首先我们需要定义牌的核心属性花色和点数。使用枚举类enum class是类型安全的最佳选择它能防止不同枚举类型之间的意外转换。// CardEnums.h #ifndef CARD_ENUMS_H #define CARD_ENUMS_H enum class Suit { Clubs, // 梅花 Diamonds, // 方块 Hearts, // 红心 Spades // 黑桃 }; enum class Rank { Ace 1, // A 设为1方便后续计算点数 Two, Three, Four, Five, Six, Seven, Eight, Nine, Ten, Jack, // J Queen, // Q King // K }; #endif // CARD_ENUMS_H这里将Rank的Ace设为 1是为了在某些扑克牌游戏中如 Blackjack方便计算点数。这是一个设计决策点需要根据实际游戏规则调整。1.2 设计 Card 类一张牌是花色和点数的不可变组合。我们使用const成员和初始化列表来确保牌一旦创建其属性就无法更改。// Card.h #ifndef CARD_H #define CARD_H #include CardEnums.h #include string class Card { public: Card(Rank rank, Suit suit); // 禁止拷贝和赋值不默认行为即可因为成员都是基础类型。 // 但为了清晰可以显式声明使用默认行为Rule of Zero。 Card(const Card) default; Card operator(const Card) default; Rank getRank() const; Suit getSuit() const; // 获取牌的点数值例如A1, J11, Q12, K13 int getValue() const; // 将牌转换为可读的字符串用于控制台输出 std::string toString() const; private: const Rank rank_; const Suit suit_; }; #endif // CARD_H对应的实现文件// Card.cpp #include Card.h #include map #include stdexcept Card::Card(Rank rank, Suit suit) : rank_(rank), suit_(suit) {} Rank Card::getRank() const { return rank_; } Suit Card::getSuit() const { return suit_; } int Card::getValue() const { // 将枚举值转换为整数 return static_castint(rank_); } std::string Card::toString() const { static const std::mapRank, std::string rankStrings { {Rank::Ace, A}, {Rank::Two, 2}, {Rank::Three, 3}, {Rank::Four, 4}, {Rank::Five, 5}, {Rank::Six, 6}, {Rank::Seven, 7}, {Rank::Eight, 8}, {Rank::Nine, 9}, {Rank::Ten, 10}, {Rank::Jack, J}, {Rank::Queen, Q}, {Rank::King, K} }; static const std::mapSuit, std::string suitStrings { {Suit::Clubs, ♣}, {Suit::Diamonds, ♦}, {Suit::Hearts, ♥}, {Suit::Spades, ♠} }; auto rankIt rankStrings.find(rank_); auto suitIt suitStrings.find(suit_); if (rankIt rankStrings.end() || suitIt suitStrings.end()) { throw std::runtime_error(Invalid card rank or suit.); } return rankIt-second suitIt-second; }注意使用static const std::map在函数内部进行映射可以避免每次调用都重新构建映射表提高效率。但要注意线程安全性在单线程场景下这是安全的。1.3 设计 Deck 类一副完整的牌组Deck应包含 52 张标准牌不含大小王。我们需要能够创建一副新牌、洗牌、发牌等操作。使用std::vectorCard作为内部容器是自然的选择。// Deck.h #ifndef DECK_H #define DECK_H #include Card.h #include vector #include random class Deck { public: Deck(); // 初始化一副完整的52张牌 void initialize(); // 洗牌 void shuffle(); // 从牌堆顶部发一张牌 Card dealCard(); // 检查牌堆是否为空 bool isEmpty() const; // 获取剩余牌数 std::size_t size() const; // 为了调试和绘制获取所有牌的只读引用 const std::vectorCard getCards() const; private: std::vectorCard cards_; // 使用 C11 的随机数引擎比 rand() 更可控 std::mt19937 rng_; }; #endif // DECK_H实现的关键在于洗牌算法。我们使用std::shuffle和std::mt19937随机数引擎。// Deck.cpp #include Deck.h #include algorithm #include chrono Deck::Deck() { // 使用当前时间作为随机种子 auto seed std::chrono::system_clock::now().time_since_epoch().count(); rng_.seed(static_castunsigned int(seed)); initialize(); } void Deck::initialize() { cards_.clear(); cards_.reserve(52); // 预分配空间避免多次扩容 for (int s static_castint(Suit::Clubs); s static_castint(Suit::Spades); s) { for (int r static_castint(Rank::Ace); r static_castint(Rank::King); r) { cards_.emplace_back(static_castRank(r), static_castSuit(s)); } } } void Deck::shuffle() { // 使用均匀分布随机打乱牌的顺序 std::shuffle(cards_.begin(), cards_.end(), rng_); } Card Deck::dealCard() { if (isEmpty()) { throw std::runtime_error(Cannot deal card from an empty deck.); } Card topCard cards_.back(); cards_.pop_back(); return topCard; } bool Deck::isEmpty() const { return cards_.empty(); } std::size_t Deck::size() const { return cards_.size(); } const std::vectorCard Deck::getCards() const { return cards_; }2. 实现绘制逻辑控制台与抽象接口“绘制”是一个与具体输出设备相关的操作。为了保持核心逻辑的纯净我们引入一个绘制器接口Renderer这样未来可以轻松替换为图形界面绘制而不需要修改Card或Deck类。2.1 定义绘制器接口// Renderer.h #ifndef RENDERER_H #define RENDERER_H #include Deck.h #include string class Renderer { public: virtual ~Renderer() default; // 绘制单张牌 virtual void render(const Card card) const 0; // 绘制整副牌可能以特定布局 virtual void render(const Deck deck) const 0; // 输出一条消息用于状态提示 virtual void renderMessage(const std::string msg) const 0; }; #endif // RENDERER_H这是一个纯虚基类任何具体的绘制器如控制台、Qt 图形都需要继承并实现这三个方法。2.2 实现控制台绘制器在控制台中我们可以用字符画的方式简单表示一张牌。一个简单的做法是每张牌占多行用字符拼出边框和花色符号。// ConsoleRenderer.h #ifndef CONSOLE_RENDERER_H #define CONSOLE_RENDERER_H #include Renderer.h class ConsoleRenderer : public Renderer { public: void render(const Card card) const override; void render(const Deck deck) const override; void renderMessage(const std::string msg) const override; private: // 辅助函数获取花色对应的颜色控制字符仅适用于支持 ANSI 转义的控制台 std::string getSuitColorCode(Suit suit) const; // 辅助函数重置颜色 std::string getResetColorCode() const; }; #endif // CONSOLE_RENDERER_H实现文件// ConsoleRenderer.cpp #include ConsoleRenderer.h #include iostream #include iomanip #ifdef _WIN32 // Windows 下启用虚拟终端处理以支持 ANSI 颜色需要 Windows 10 及以上 #include windows.h // 此处省略了复杂的 Windows 控制台颜色初始化代码建议使用跨平台库如 fmt 或 spdlog // 为简化本例在 Windows 下暂时不使用颜色 #define USE_COLOR 0 #else // Linux/macOS 通常支持 ANSI 颜色 #define USE_COLOR 1 #endif void ConsoleRenderer::render(const Card card) const { std::string rankStr card.toString().substr(0, card.toString().size() - 3); // 去掉花色符号只留点数 std::string suitStr card.toString().substr(card.toString().size() - 3); // 获取花色符号可能是多字节字符 std::string colorCode getSuitColorCode(card.getSuit()); std::string resetCode getResetColorCode(); // 简单的字符画牌面 std::cout colorCode; std::cout ┌─────┐\n; std::cout │ std::left std::setw(2) rankStr │\n; std::cout │ suitStr │\n; std::cout │ std::right std::setw(2) rankStr │\n; std::cout └─────┘ resetCode std::endl; } void ConsoleRenderer::render(const Deck deck) const { const auto cards deck.getCards(); if (cards.empty()) { renderMessage(The deck is empty.); return; } renderMessage(Deck contains std::to_string(cards.size()) cards:); // 简单遍历绘制每张牌实际可能需要更复杂的布局这里每张牌单独一行 for (const auto card : cards) { render(card); } } void ConsoleRenderer::renderMessage(const std::string msg) const { std::cout [INFO] msg std::endl; } std::string ConsoleRenderer::getSuitColorCode(Suit suit) const { #if USE_COLOR switch (suit) { case Suit::Hearts: case Suit::Diamonds: return \033[31m; // 红色 case Suit::Clubs: case Suit::Spades: return \033[34m; // 蓝色 default: return \033[0m; } #else return ; #endif } std::string ConsoleRenderer::getResetColorCode() const { #if USE_COLOR return \033[0m; #else return ; #endif }注意控制台颜色输出依赖于 ANSI 转义序列在 Windows 的旧版控制台中默认不支持。上述代码通过预编译指令做了简单处理。在生产项目中建议使用像fmt这样的库来处理跨平台彩色输出或者直接使用图形库。2.3 设计绘制策略与布局上面的ConsoleRenderer::render(const Deck)只是简单地将每张牌垂直排列这并不直观。一个更好的“绘制牌组”可能是将多张牌水平排列或者模拟一个发牌堆叠的效果。这引出了另一个重要的设计点绘制策略。我们可以将布局算法也抽象出来。// LayoutStrategy.h #ifndef LAYOUT_STRATEGY_H #define LAYOUT_STRATEGY_H #include vector #include string class Card; // 前向声明 class LayoutStrategy { public: virtual ~LayoutStrategy() default; // 给定一组牌返回一个字符串向量每个元素代表输出的一行 virtual std::vectorstd::string layout(const std::vectorCard cards) const 0; }; #endif // LAYOUT_STRATEGY_H然后实现一个简单的水平布局策略// HorizontalLayout.h #ifndef HORIZONTAL_LAYOUT_H #define HORIZONTAL_LAYOUT_H #include LayoutStrategy.h class HorizontalLayout : public LayoutStrategy { public: std::vectorstd::string layout(const std::vectorCard cards) const override; }; #endif // HORIZONTAL_LAYOUT_H// HorizontalLayout.cpp #include HorizontalLayout.h #include Card.h #include vector #include string #include sstream std::vectorstd::string HorizontalLayout::layout(const std::vectorCard cards) const { if (cards.empty()) { return {[Empty]}; } // 每张牌的字符画高度是固定的例如5行 const int cardHeight 5; std::vectorstd::string lines(cardHeight, ); for (const auto card : cards) { std::string rankStr card.toString().substr(0, card.toString().size() - 3); std::string suitStr card.toString().substr(card.toString().size() - 3); // 构建单张牌的5行表示 std::vectorstd::string cardLines { ┌─────┐, │ rankStr │, │ suitStr │, │ rankStr │, └─────┘ }; // 将这张牌的每一行追加到总输出行 for (int i 0; i cardHeight; i) { lines[i] cardLines[i] ; // 两张牌之间加两个空格 } } return lines; }然后修改ConsoleRenderer使其接受一个LayoutStrategy并用于绘制整副牌。这样绘制器的职责就清晰了render(const Card)负责单张牌render(const Deck)负责使用策略布局多张牌。3. 构建主程序与测试流程有了核心的Card、Deck、Renderer和LayoutStrategy我们可以组装主程序来验证整个逻辑。3.1 编写主函数// main.cpp #include Deck.h #include ConsoleRenderer.h #include HorizontalLayout.h #include iostream #include memory int main() { try { // 1. 创建一副牌并初始化 Deck deck; std::cout Initial deck size: deck.size() std::endl; // 2. 创建渲染器和布局策略 auto renderer std::make_uniqueConsoleRenderer(); auto layout std::make_uniqueHorizontalLayout(); // 3. 绘制初始牌组前5张避免控制台输出过长 renderer-renderMessage(First 5 cards of the initial deck:); // 为了演示临时取出前5张牌 Deck tempDeck; // 这里简化处理实际应修改 Deck 类提供 peek 功能或使用迭代器 // 我们先洗牌然后发5张牌来展示 deck.shuffle(); std::vectorCard firstFive; for (int i 0; i 5 !deck.isEmpty(); i) { firstFive.push_back(deck.dealCard()); } // 使用布局策略绘制这5张牌 auto lines layout-layout(firstFive); for (const auto line : lines) { std::cout line std::endl; } // 4. 发一张牌并绘制 if (!deck.isEmpty()) { Card dealt deck.dealCard(); renderer-renderMessage(Dealt one card:); renderer-render(dealt); } // 5. 显示剩余牌数 renderer-renderMessage(Remaining cards in deck: std::to_string(deck.size())); // 6. 尝试从空牌堆发牌应抛出异常 // 为了演示异常处理我们创建一个新牌组并抽空它 Deck emptyDeck; emptyDeck.initialize(); while (!emptyDeck.isEmpty()) { emptyDeck.dealCard(); } try { emptyDeck.dealCard(); } catch (const std::runtime_error e) { renderer-renderMessage(std::string(Exception caught: ) e.what()); } } catch (const std::exception e) { std::cerr Fatal error: e.what() std::endl; return 1; } return 0; }3.2 配置构建系统CMake为了跨平台编译使用 CMake 管理项目是最佳实践。创建一个CMakeLists.txt文件cmake_minimum_required(VERSION 3.10) project(PaintedDeck VERSION 1.0.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 添加可执行文件 add_executable(PaintedDeck main.cpp Card.cpp Deck.cpp ConsoleRenderer.cpp HorizontalLayout.cpp ) # 包含头文件目录 target_include_directories(PaintedDeck PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})在项目根目录下执行以下命令编译mkdir build cd build cmake .. cmake --build . --config Release在 Windows 上如果使用 Visual Studio可以用cmake --build . --config Release或直接打开生成的.sln文件。在 Linux/macOS 上默认生成 Makefile使用make即可。3.3 运行与验证编译成功后运行生成的可执行文件如./PaintedDeck或PaintedDeck.exe。预期输出应类似于Initial deck size: 52 [INFO] First 5 cards of the initial deck: ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │K │ │Q │ │5 │ │2 │ │9 │ │ ♠ │ │ ♥ │ │ ♦ │ │ ♣ │ │ ♠ │ │ K│ │ Q│ │ 5│ │ 2│ │ 9│ └─────┘ └─────┘ └─────┘ └─────┘ └─────┘ [INFO] Dealt one card: ┌─────┐ │A │ │ ♣ │ │ A│ └─────┘ [INFO] Remaining cards in deck: 46 [INFO] Exception caught: Cannot deal card from an empty deck.如果控制台不支持 Unicode 或 ANSI 颜色花色符号和边框可能显示为乱码或问号。这是控制台环境差异导致的不影响核心逻辑。4. 常见问题、陷阱与排查在实现上述“Painted Deck”项目时开发者常会遇到以下几类问题。4.1 编译与链接错误问题现象常见原因检查方式处理建议undefined reference to链接错误1. 实现文件.cpp未加入编译。2. 函数声明与定义不匹配const、参数类型。3. 未正确包含头文件。1. 检查 CMakeLists.txt 或 Makefile 是否列出了所有 .cpp 文件。2. 对比 .h 和 .cpp 中的函数签名。3. 使用#pragma once或规范的#ifndef守卫防止重复包含。确保每个类都有对应的 .cpp 文件被编译且头文件守卫唯一。enum class值无法转换为int直接使用enum class值进行算术运算。检查代码中是否直接对Rank或Suit进行了、-运算。使用static_castint(yourEnum)进行显式转换。控制台输出乱码源代码文件编码与控制台编码不匹配特别是中文或特殊符号。检查 IDE 或编辑器保存文件的编码应为 UTF-8 without BOM。在 Windows 上编译前执行chcp 65001切换控制台代码页为 UTF-8。或将特殊符号改为 ASCII 艺术。4.2 运行时逻辑错误问题现象常见原因检查方式处理建议洗牌后顺序看起来没变随机数种子固定或std::shuffle使用错误。检查std::mt19937的种子是否每次运行都不同如使用时间。确认std::shuffle的参数顺序(begin, end, rng)。使用std::random_device或std::chrono生成种子。确保rng是左值引用。牌的花色或点数不对enum class的底层整数值赋值错误或循环边界错误。在Deck::initialize()中打印每张牌的toString()进行调试。确认for循环的起始和结束值。使用static_cast明确转换并验证enum class的底层值是否符合预期。尝试从空牌组发牌导致崩溃未检查isEmpty()就直接调用dealCard()。在dealCard()入口添加断言或异常抛出。始终在发牌前检查牌组状态或像示例一样在dealCard()内部检查并抛出异常。4.3 设计陷阱与改进过度使用继承本例中Renderer和LayoutStrategy使用了继承和多态。对于简单项目这没问题。但如果绘制方式非常固定使用策略模式或简单函数对象std::function可能更轻量。性能过早优化在Card::toString()中使用了static std::map进行查找。对于 52 张牌这完全足够。但如果需要每秒处理数百万张牌可以考虑用数组或switch语句替代映射。异常安全Deck::dealCard()在空时抛出异常。调用者必须处理异常。另一种设计是返回std::optionalCard让调用者检查是否有值这更符合现代 C 的“避免异常”风格。线程安全当前的Deck和Renderer都不是线程安全的。如果多个线程同时操作同一副牌需要加锁。通常牌组对象不应被多线程共享或者将其设计为不可变对象每次操作返回新状态。5. 扩展方向与生产环境考量这个基础项目可以沿多个方向扩展成为一个功能完整的卡牌游戏引擎或图形演示程序。5.1 扩展方向图形界面集成创建另一个Renderer派生类如QtRenderer使用 Qt 的QPainter在窗口上绘制精美的牌面。这需要链接 Qt 库并处理事件循环。网络功能将Deck状态序列化为 JSON 或二进制格式通过网络发送实现多人卡牌游戏。游戏规则引擎引入Game类管理玩家、回合、胜负规则。Card可以增加游戏特定的属性如点数、技能。单元测试使用 Google Test 或 Catch2 为Card、Deck和洗牌算法编写单元测试确保逻辑正确。性能剖析使用性能分析工具如perf、VTune检查洗牌、渲染的瓶颈对于需要实时渲染的场景尤为重要。5.2 生产环境考量若将此项目用于实际游戏或应用需注意以下几点配置管理牌组大小、花色定义、点数规则应通过配置文件如 JSON、YAML加载而非硬编码在枚举中。日志系统替换std::cout为专业的日志库如 spdlog支持日志级别、文件输出和异步日志便于线上问题排查。资源管理如果使用图形界面牌面图片、字体等资源文件应有清晰的加载和释放机制避免内存泄漏。错误处理定义项目统一的错误码或异常体系避免随意使用std::runtime_error。跨平台支持使用 CMake 管理依赖确保在 Windows、Linux、macOS 上都能编译。对于控制台颜色等平台相关功能使用条件编译或第三方库如fmt、ncurses进行抽象。代码规范与静态分析使用clang-format统一代码风格使用clang-tidy或cppcheck进行静态代码分析提前发现潜在问题。通过这个从零开始的“Painted Deck”项目我们不仅实现了一个可运行的 C 程序更实践了面向对象设计、接口抽象、资源管理和错误处理等核心概念。下次当你面对一个看似杂乱的 C 项目需求时可以尝试先拆解核心实体如 Card、定义行为接口如 Renderer、组合成完整流程最后再考虑扩展和优化。这种分而治之的方法能让“疯狂”的编码过程变得有序且可控。
返回列表