简介:本资源是一套基于MFC开发的动物专家系统完整源码工程,面向C++与人工智能初学者、高校课程设计学生及AI推理算法实践者,旨在通过可视化界面直观理解正向与逆向推理机制在知识系统中的落地实现。压缩包共44个文件,涵盖6个头文件(.h)定义类结构与接口、3个源文件(.cpp)实现核心推理逻辑、2个可执行文件(.exe)支持免环境直接运行、1个解决方案文件(.sln)及配套资源文件(.rc、.ico、.res),另有编译中间产物(.obj、.pdb、.tlog等)体现VS2019完整构建流程,整体大小为71.31MB。已有739人学习下载,读者可获得可运行的MFC图形界面程序、带注释的正向/逆向双模推理源码、清晰分层的项目目录结构(含UI层、逻辑层、资源层),以及从知识库建模到规则引擎调用的完整技术路径参考。
1. 动物专家系统不是“AI玩具”,而是MFC环境下正向/逆向推理逻辑的完整工程实践
你打开一个.zip文件,里面是AnimalExpertSystem.sln和一堆.cpp/.h文件,双击运行后弹出带“哺乳类”“鸟类”“爬行类”下拉框的对话框——这不是教学Demo,而是一个可调试、可扩展、能真实走通规则引擎闭环的MFC桌面应用。它用C++在Windows原生框架里实现了知识库加载、事实断言、规则匹配、路径回溯与解释链生成,核心价值不在“识别动物”,而在把人工智能课程里抽象的推理机制,落地成VC++工程中可单步调试的指针操作和消息响应。适合正在做软件综合实践、智能系统课程设计或想补足MFC+规则引擎实战能力的开发者——尤其当你发现教材里的“正向推理伪代码”在VS2019里编译不过、CListCtrl插入规则时崩溃、或者逆向推理的回溯栈总越界时,这个源码包就是你该逐行对照的参照系。它不依赖第三方AI库,所有推理逻辑由CInferenceEngine类封装,知识库以文本规则文件驱动,真正体现“用MFC写专家系统”的技术纵深。
2. 正向推理引擎:从规则文件解析到冲突消解的MFC消息驱动实现
正向推理不是简单遍历规则,而是在MFC消息循环中构建事实集、触发规则匹配、处理冲突并更新UI。本项目采用基于规则优先级+最近使用时间的混合冲突策略,避免死循环和规则饥饿。
2.1 规则文件格式与CRuleSet类解析逻辑
规则存储于rules.txt,每行一条,格式为:IF (毛发=是) AND (哺乳=是) THEN (类别=哺乳类) PRIORITY=80
其中PRIORITY字段决定冲突时的激活顺序(数值越大越优先)。CRuleSet类负责加载与索引:
// CRuleSet.h class CRuleSet { public: struct SCondition { CString field; CString value; }; struct SAction { CString field; CString value; }; struct SRule { int priority; std::vector<SCondition> conditions; std::vector<SAction> actions; bool bActivated; // 是否已触发过 }; std::vector<SRule> m_rules; void LoadFromFile(LPCTSTR lpszPath); // 实际解析函数 };提示:
LoadFromFile中关键点在于用AfxExtractSubString拆分AND条件,并对每个field=value对调用Trim()去除空格。若字段名含中文(如“毛发”),必须确保rules.txt保存为UTF-8 with BOM,否则CString解析会乱码。
2.2 推理主循环:OnStartForwardInference() 的三阶段执行流
点击“正向推理”按钮触发CMainFrame::OnStartForwardInference(),其执行分三阶段:
2.2.1 事实初始化与工作内存构建
// CMainFrame.cpp void CMainFrame::OnStartForwardInference() { // 1. 清空当前事实集(m_facts 是 CMapStringToString) m_facts.RemoveAll(); // 2. 从UI控件读取初始事实(例:ComboBox选中值) CComboBox* pCB = (CComboBox*)GetDlgItem(IDC_COMBO_HAIR); int nSel = pCB->GetCurSel(); if (nSel != CB_ERR) { CString strVal; pCB->GetLBText(nSel, strVal); m_facts["毛发"] = strVal; // 键为中文字段名,值为选项文本 } // 3. 启动推理线程(避免UI冻结) AfxBeginThread(ForwardInferenceThreadProc, this); }注意:MFC中严禁在UI线程直接执行长耗时推理。此处用
AfxBeginThread启动独立线程,但需通过PostMessage向主线程发送WM_UPDATE_FACTS消息刷新列表控件,避免跨线程访问CListCtrl。
2.2.2 规则匹配与冲突消解核心算法
ForwardInferenceThreadProc调用CInferenceEngine::RunForward(),其核心逻辑:
// CInferenceEngine.cpp int CInferenceEngine::RunForward(CMapStringToString& facts, CRuleSet& rules) { bool bChanged = true; int nCycle = 0; while (bChanged && nCycle++ < 100) { // 防死循环 bChanged = false; std::vector<CRuleSet::SRule*> candidates; // 遍历所有未激活规则,收集满足条件的候选 for (auto& rule : rules.m_rules) { if (rule.bActivated) continue; bool bMatch = true; for (const auto& cond : rule.conditions) { CString strFact; if (!facts.Lookup(cond.field, strFact) || strFact != cond.value) { bMatch = false; break; } } if (bMatch) candidates.push_back(&rule); } // 冲突消解:按PRIORITY降序,取第一个 if (!candidates.empty()) { std::sort(candidates.begin(), candidates.end(), [](const CRuleSet::SRule* a, const CRuleSet::SRule* b) { return a->priority > b->priority; }); auto pRule = candidates[0]; // 执行动作:更新事实集 for (const auto& act : pRule->actions) { facts[act.field] = act.value; bChanged = true; } pRule->bActivated = true; // 标记已触发 } } return nCycle; }参数说明:
nCycle限制最大迭代次数,防止规则间形成循环依赖(如规则A推导B,规则B又推导A)。bActivated标志位确保每条规则只触发一次,符合经典正向链特性。
2.2.3 UI同步:CListCtrl动态刷新事实链
推理完成后,主线程收到WM_UPDATE_FACTS消息,在OnUpdateFacts()中刷新列表:
// CMainFrame.cpp LRESULT CMainFrame::OnUpdateFacts(WPARAM wParam, LPARAM lParam) { CListCtrl* pList = (CListCtrl*)GetDlgItem(IDC_LIST_FACTS); pList->DeleteAllItems(); POSITION pos = m_facts.GetStartPosition(); int i = 0; while (pos) { CString key, value; m_facts.GetNextAssoc(pos, key, value); pList->InsertItem(i, key); pList->SetItemText(i, 1, value); i++; } return 0; }关键细节:
CListCtrl列宽需在OnInitDialog()中手动设置,否则中文列名显示不全:pList->SetColumnWidth(0, 120); pList->SetColumnWidth(1, 150);
3. 逆向推理引擎:目标驱动的深度优先回溯与解释路径生成
逆向推理不是“正向的反向”,而是以用户输入的目标结论(如“类别=鸟类”)为起点,递归寻找支持该结论的所有前提条件,并生成可追溯的推理路径。本项目通过CBackwardEngine类实现,其难点在于避免重复访问、管理回溯栈、以及将路径转化为用户可读的自然语言解释。
3.1 目标设定与路径初始化:OnStartBackwardInference() 的预处理
点击“逆向推理”按钮后,首先弹出输入框让用户指定目标:
// CMainFrame.cpp void CMainFrame::OnStartBackwardInference() { CString strTarget; if (CInputDlg::GetInput(_T("请输入目标结论,格式如:类别=鸟类"), strTarget) == IDOK) { // 解析目标:拆分"类别=鸟类"为 field="类别", value="鸟类" int nPos = strTarget.Find('='); if (nPos == -1) return; CString strField = strTarget.Left(nPos).Trim(); CString strValue = strTarget.Mid(nPos+1).Trim(); // 初始化回溯栈(存储待验证的子目标) std::stack<std::pair<CString, CString>> stack; stack.push({strField, strValue}); // 启动逆向线程 AfxBeginThread(BackwardInferenceThreadProc, new SBackwardParam{this, stack, strTarget}); } }注意:
CInputDlg是自定义对话框类,确保其DoModal()在UI线程调用,参数通过SBackwardParam结构体传递给工作线程,避免数据竞争。
3.2 回溯核心:DFS搜索与规则反向匹配
BackwardInferenceThreadProc调用CBackwardEngine::RunBackward(),采用深度优先搜索(DFS)遍历规则库:
// CBackwardEngine.cpp bool CBackwardEngine::RunBackward( CMapStringToString& facts, CRuleSet& rules, std::stack<std::pair<CString, CString>>& stack, std::vector<std::vector<std::pair<CString, CString>>>& paths) { while (!stack.empty()) { auto target = stack.top(); stack.pop(); CString& field = target.first; CString& value = target.second; // 检查是否已是已知事实 CString strKnown; if (facts.Lookup(field, strKnown) && strKnown == value) { // 目标已满足,记录路径节点 paths.back().push_back({field, value}); continue; } // 查找能推出该目标的规则(即action中包含field=value) bool bFound = false; for (auto& rule : rules.m_rules) { for (const auto& act : rule.actions) { if (act.field == field && act.value == value) { // 找到支持规则,将其所有conditions压入栈 for (const auto& cond : rule.conditions) { stack.push({cond.field, cond.value}); } // 记录当前推理步骤(用于生成解释) paths.back().push_back({field, value}); paths.back().push_back({ _T("由规则"), FormatRuleID(rule.priority) }); // 如"由规则#80" bFound = true; break; } } if (bFound) break; } if (!bFound) { // 无规则支持该目标,需用户输入(如询问“是否产卵?”) PostMessageToUI(WM_ASK_USER, (WPARAM)&target); return false; // 等待用户响应 } } return true; }参数说明:
paths是二维向量,每个内层vector存储一条完整推理路径;FormatRuleID()将优先级转为可读ID(如80→#80),便于后续生成解释文本。
3.3 解释链生成:将回溯路径转为自然语言描述
推理完成后,CBackwardEngine调用GenerateExplanation()生成用户友好的解释:
// CBackwardEngine.cpp CString CBackwardEngine::GenerateExplanation( const std::vector<std::vector<std::pair<CString, CString>>>& paths) { CString strExp; strExp += _T("推理路径:\r\n"); for (size_t i = 0; i < paths.size(); ++i) { const auto& path = paths[i]; for (size_t j = 0; j < path.size(); ++j) { const auto& item = path[j]; if (item.first == _T("由规则")) { strExp += _T(" → ") + item.second + _T("\r\n"); } else { strExp += _T(" ") + item.first + _T("=") + item.second + _T("\r\n"); } } if (i < paths.size()-1) strExp += _T("\r\n"); } return strExp; }实际效果:若目标为“类别=鸟类”,解释链可能为:
推理路径:毛发=否产卵=是→ 由规则#75类别=鸟类
这种结构清晰展示“为什么得出该结论”,是专家系统区别于黑盒模型的关键。
4. MFC界面与知识库协同:控件绑定、规则热加载与调试技巧
MFC界面不是静态容器,而是与推理引擎实时交互的控制中心。本项目通过消息映射、控件ID关联和文本文件监听,实现UI与知识库的松耦合。
4.1 控件与字段的双向绑定:CFieldBinder类的设计
为避免硬编码控件ID与字段名的映射,引入CFieldBinder类统一管理:
// CFieldBinder.h class CFieldBinder { public: struct SBinding { int nCtrlID; // 控件ID,如 IDC_COMBO_HAIR CString strField; // 对应知识库字段名,如 "毛发" int nCtrlType; // 1=ComboBox, 2=CheckBox, 3=Edit }; std::vector<SBinding> m_bindings; void BindToDialog(CDialog* pDlg); void UpdateFactsFromUI(CMapStringToString& facts); void UpdateUIFromFacts(CMapStringToString& facts); };在CMainFrame::OnInitDialog()中注册绑定:
// CMainFrame.cpp BOOL CMainFrame::OnInitDialog() { CDialog::OnInitDialog(); m_binder.m_bindings = { {IDC_COMBO_HAIR, _T("毛发"), 1}, {IDC_CHECK_FEATHER, _T("羽毛"), 2}, {IDC_EDIT_EGGS, _T("产卵"), 3} }; m_binder.BindToDialog(this); return TRUE; }优势:新增字段(如“水生”)只需在
m_bindings添加一行,无需修改OnStartForwardInference()中的取值逻辑,大幅提升可维护性。
4.2 规则文件热加载:监控rules.txt变更并重载
为支持调试时修改规则立即生效,添加文件监控:
// CMainFrame.cpp void CMainFrame::StartRuleFileWatch() { m_hWatch = FindFirstChangeNotification( _T(".\\"), // 监控当前目录 FALSE, // 不监控子目录 FILE_NOTIFY_CHANGE_LAST_WRITE); // 仅当文件被写入时触发 if (m_hWatch != INVALID_HANDLE_VALUE) { // 在定时器中轮询(MFC无异步IO Completion Port) SetTimer(IDT_RULE_WATCH, 500, nullptr); } } void CMainFrame::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent == IDT_RULE_WATCH) { if (WaitForSingleObject(m_hWatch, 0) == WAIT_OBJECT_0) { // 文件被修改,重新加载规则 m_ruleSet.LoadFromFile(_T("rules.txt")); // 重置所有规则激活状态 for (auto& rule : m_ruleSet.m_rules) rule.bActivated = false; FindNextChangeNotification(m_hWatch); } } CFrameWnd::OnTimer(nIDEvent); }注意:
FindFirstChangeNotification需配合FindNextChangeNotification使用,否则只触发一次。定时器间隔设为500ms,平衡响应速度与CPU占用。
4.3 调试关键点:VS2013/2019中定位推理卡死与内存泄漏
当推理过程无响应或CListCtrl显示异常时,按以下顺序排查:
| 现象 | 定位方法 | 修复方案 |
|---|---|---|
| 正向推理无限循环 | 在RunForward()循环内设断点,观察nCycle值及facts内容变化 | 检查rules.txt是否存在循环规则(A→B, B→A),增加nCycle上限至50并打印日志 |
| 逆向推理栈溢出 | 查看调用堆栈深度,确认stack.push()是否无终止条件 | 在RunBackward()中添加stack.size() > 20的保护判断,防止深层嵌套 |
| UI控件更新失败 | 在OnUpdateFacts()中检查pList是否为NULL,或GetDlgItem()返回失败 | 确保IDC_LIST_FACTS在对话框资源中正确声明,且CListCtrl控件类型为CListCtrl而非CListBox |
| 中文字段解析失败 | 在LoadFromFile()中AfxMessageBox(strLine)查看原始行内容 | 将rules.txt用记事本另存为UTF-8 with BOM,并在LoadFromFile()开头添加setlocale(LC_ALL, "Chinese"); |
提示:启用MFC调试内存检测,在
stdafx.h末尾添加:#define _CRTDBG_MAP_ALLOC#include <crtdbg.h>
并在InitInstance()开头调用_CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF \| _CRTDBG_LEAK_CHECK_DF);,程序退出时自动报告内存泄漏。
5. 工程化进阶:VS2013兼容性适配、多线程安全与规则库版本管理
源码包默认在VS2013环境开发,但需适配更高版本VS(如2019)并保障多线程下的数据一致性。同时,规则库的版本控制是生产部署的关键环节。
5.1 VS2013到VS2019的平滑迁移:三个必须修改的配置项
VS2019默认使用v142工具集,而VS2013为v120,直接打开会导致afxwin.h编译错误。需手动调整:
- 平台工具集:右键项目 → 属性 → 通用属性 → 平台工具集 → 改为
Visual Studio 2013 (v120)(若需新特性再逐步升级) - 字符集:属性 → 常规 → 字符集 → 改为
使用Unicode字符集(VS2013默认即Unicode,VS2019新建项目可能为多字节) - MFC引用路径:属性 → 常规 → 使用MFC → 改为
在共享DLL中使用MFC(避免静态链接导致的CWinApp构造问题)
验证方法:编译后运行,检查
CMainFrame构造函数中AfxMessageBox(_T("Hello"))是否正常弹出。若报错LNK2001: unresolved external symbol __imp__AfxGetModuleState,即为MFC引用方式错误。
5.2 多线程安全:CMapStringToString的临界区保护
m_facts被正向/逆向线程及UI线程并发访问,需加锁:
// CMainFrame.h class CMainFrame : public CFrameWnd { // ... CCriticalSection m_factLock; // 新增临界区对象 CMapStringToString m_facts; }; // CMainFrame.cpp void CMainFrame::UpdateFact(const CString& key, const CString& value) { m_factLock.Lock(); m_facts[key] = value; m_factLock.Unlock(); } CString CMainFrame::GetFact(const CString& key) { CString val; m_factLock.Lock(); m_facts.Lookup(key, val); m_factLock.Unlock(); return val; }注意:
CCriticalSection是MFC封装的Windows临界区,比std::mutex更轻量,且无需额外链接库。所有对m_facts的读写操作必须经过UpdateFact()/GetFact()封装。
5.3 规则库版本管理:rules_v1.txt 与 rules_v2.txt 的切换机制
为支持A/B测试或回滚,实现规则版本切换:
// CMainFrame.cpp void CMainFrame::OnSwitchRuleVersion() { static int nVersion = 1; CString strFile = nVersion == 1 ? _T("rules_v1.txt") : _T("rules_v2.txt"); if (m_ruleSet.LoadFromFile(strFile)) { AfxMessageBox(_T("规则库已切换至 ") + strFile); nVersion = 3 - nVersion; // 1→2, 2→1 } else { AfxMessageBox(_T("加载规则文件失败:") + strFile); } }生产建议:在
rules_v*.txt文件头添加版本注释:# VERSION: 1.2.0# AUTHOR: AnimalExpertTeam# DATE: 2023-10-15
并在LoadFromFile()中解析该行,写入日志供运维追踪。
在CMainFrame::OnStartForwardInference()中插入版本检查日志:TRACE(_T("当前规则版本:%s\r\n"), GetRuleVersion());
这样每次推理启动时,Output窗口都会输出所用规则版本,为问题复现提供关键线索。
本文还有配套的精品资源,点击获取