ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++ Qt词法分析器实战:从正则到NFA/DFA状态机可视化

C++ Qt词法分析器实战:从正则到NFA/DFA状态机可视化 简介这是一份基于C与Qt实现的词法分析器工程面向编译原理课程设计和编译器初学者用来解决源代码分词与标记生成的问题。工具可以将源码文本读取拆分得到关键字、标识符、数字、运算符等词法单元并通过图形窗口展示分析结果。压缩包内共有三十个文件涵盖六个C源文件、四个头文件、界面定义文件、资源文件、自动机图文件、运行截图和课设报告整体约571KB结构清晰便于查找。核心逻辑实现于词法模块主窗口负责交互图形组件完成可视化测试代码提供功能校验课设文档记录设计流程。已有188人学习下载该工程将抽象编译原理落地为可直接运行的代码对理解词法分析状态转换、匹配规则以及图形界面整合很有帮助。1. C Qt 词法分析器不只是一把正则梭子而是完整的自动机生产线提到词法分析器多数人第一反应是拿正则表达式匹配关键字、数字、标识符匹配完就出 token。但这个项目不只是这样它把整条编译原理链路都摆了出来——正则表达式先构造成 NFANFA 用子集构造法转成 DFADFA 再做最小化每一步都生成 Graphviz 的 dot 文件并用 Qt 的图形界面把 NFA、DFA、最小化 DFA 三张图直接画给你看。这意味着你下载的不只是一个能识别的工具而是一套看得见状态转换过程的编译原理实验平台。它适合正在写编译原理课设的学生也适合想搞清楚状态机如何落到 C 代码的开发者——尤其是你之前只写过递归下降但没碰过自动机构造或者想看看 Qt 怎么把理论图渲染成界面的人。2. 拆解 lex.cpp记号定义、关键字表与 token 的完整扫描路径2.1 记号类型与关键字表先定语言再写代码词法分析器第一步不是写扫描函数而是定义你要分析的语言到底有哪些记号类别。常见的做法是定义一个枚举类型把关键字、标识符、整型常量、浮点常量、运算符、分隔符全部列出来再给一个 EOF 标记和非法字符标记。这个项目的 lex.h 里应该就有一份类似的结构下面是我在类似课设中的典型写法enum class TokenType { IDENTIFIER, // 标识符 INT_CONST, // 整型常量 FLOAT_CONST, // 浮点常量 KEYWORD, // 关键字 OPERATOR, // 运算符 DELIMITER, // 分隔符 EOF_TOKEN, // 文件结束 ILLEGAL // 非法字符 };这里把 KEYWORD 单独拎出来而不是并进 IDENTIFIER有个实际好处语法分析阶段拿到 token 后不需要每次都用字符串比较来判断这个标识符是不是 if直接看 token 类型就行效率高代码也干净。关键字表建议用std::unordered_map而不是一串if-else。你从源码里读到一个标识符后先在哈希表里查一下命中就是关键字没命中就是普通标识符。词法分析是高频循环每读一个 token 都要查一次哈希表均摊 O(1)比写十几个strcmp清爽得多const std::unordered_mapstd::string, TokenType keywordMap { {int, TokenType::KEYWORD}, {float, TokenType::KEYWORD}, {if, TokenType::KEYWORD}, {else, TokenType::KEYWORD}, {while, TokenType::KEYWORD}, {return, TokenType::KEYWORD}, {void, TokenType::KEYWORD} };我见过不少人把关键字判断直接写进扫描循环里用if (lexeme if)一路列到底结果加一个关键字就要改一遍扫描函数非常容易漏。拆成枚举加哈希表之后加关键字只需要改 keywordMap 一处扫描逻辑完全不用动这就是解耦的价值。2.2 状态转移表DFA 的核心数据结构词法分析器的扫描过程可以不走逐字符 if-else而是用一张状态转移表驱动。这张表的行是状态编号列是输入字符或字符类别交叉点存放下一个状态编号。这样扫描函数就变成简单的查表循环std::vectorstd::vectorint transitionTable;比如一个只认数字的状态子表可以看得非常直观0 表示非法-1 表示接收态。实际工程里这张表可以直接从项目里的 mindfa.dot 解析生成也可以手工定义二维数组。状态机表驱动的好处是逻辑和数据结构分离——你改词法规则时只需要改表不需要动扫描器主体。这个思想在语法分析里也一样一张 LR 分析表顶得上几百行手写逻辑。扫描函数的核心逻辑大体是这样的// state 初始为 0即起始状态 int state 0; std::string lexeme; while (state 0) { char c peekChar(); if (c EOF) { state -1; break; } int col charClass(c); // 把字符映射到列号 int next transitionTable[state][col]; if (next 0) { // 无法继续前进当前 lexeme 作为一个 token 结束 emitToken(lexeme); lexeme.clear(); state 0; // 回到起始状态 } else { lexeme.push_back(c); getChar(); // 消费掉当前字符 state next; } }这段代码有几个关键点。charClass(c)把字符映射成列号比如字母都映射到 0数字映射到 1运算符映射到 2空白映射到 3其它字符映射到 4。这样做是为了压缩状态表体积否则你要给 ASCII 码 128 个字符各留一列表会变得很大且大部分是冗余列。next 0时说明当前状态没有接收这条边token 到这里就该切断了。有一点必须注意到达接收态时不能立刻消费下一个字符否则会把下一个 token 的首字符吞掉。常见做法是先把当前 token 记下来然后回到起始状态重新扫描下一轮循环再读新字符。这也是词法分析器最容易翻车的地方——边界处理不对就会出现 token 错位、漏字符、死循环。2.3 一次完整的 token 扫描从源码字符到 token 流把上面两段拼起来完整的驱动逻辑是这样的mainwindow 拿到源码字符串后把它转换成字符流逐字符喂给扫描器。扫描器维护一个当前位置指针pos每消费一个字符就pos。遇到空白、换行、注释直接跳过不生成 token。遇到标识符开头字符就走标识符状态子集遇到数字开头就走数字状态子集。识别完一个 token 后把 token 的类型和 lexeme 打包送到 token 流里bool scanner::nextToken(Token out) { // 跳过空白和注释 skipTrivia(); if (pos src.length()) { out.type TokenType::EOF_TOKEN; return false; } char ch src[pos]; if (isAlpha(ch)) return scanIdentifier(out); if (isDigit(ch)) return scanNumber(out); if (isOperator(ch)) return scanOperator(out); // 其他字符按分隔符或非法字符处理 return scanMisc(out); }skipTrivia()专门处理空格、换行、//注释和/* */注释。这里有个容易被忽略的细节注释识别优先级要放在关键字识别之前否则遇到//会被当成两个除号运算符。项目里如果把//当作单行注释处理必须确认 lex.cpp 里是先检查//再检查/的顺序反了整个注释功能就废了。这一章看下来你会发现词法分析器的核心其实不是字符串匹配而是状态机跳转。正则匹配方案看起来简单但一个字符被重复消费、规则优先级混乱、修改规则后牵一发而动全身的问题会接踵而来。状态表驱动则把规则和执行分离这也是项目里 lex.cpp 和 lexical.cpp 分文件的原因——lex 负责扫描执行lexical 负责规则编译和表生成。3. NFA、DFA 与最小化三张 dot 图的生成逻辑与可视化思路3.1 Thompson 构造正则表达式如何变成 NFA项目里有 nfa.dot、dfa.dot、mindfa.dot 三个 Graphviz 文件对应的 nfa.jpg、dfa.jpg、mindfa.jpg 三张图说明这个课设完整走了一遍正则表达式 → NFA → DFA → 最小化 DFA的经典路线。第一步是用 Thompson 构造法把正则表达式转换成带 ε 转移的 NFA。Thompson 构造法的核心思想是每一种正则表达式结构都有对应的 NFA 片段拼装时通过 ε 转移把它们串起来。最基本的三个片段是单个字符匹配、连接ab、选择a|b另外还有闭包a*。拿选择操作a|b来说构造出来的 NFA 片段是这样的digraph NFA { start - nfa_a; start - nfa_b; nfa_a - accept; nfa_b - accept; }用一个新起始状态空转移分发到两个子 NFA两个子 NFA 的接受状态再空转移汇聚到一个新接受状态。闭包a*则是新建两个状态起始状态空转移到子 NFA 和接受状态接受状态空转移回子 NFA这样既能走零次也能走多次。这个过程可以用递归实现先把正则表达式解析成语法树再对语法树做后序遍历每遇到一个节点就用 Thompson 规则扩展 NFA 的边集合。实际编码时NFA 可以用状态集合加边集合表示。边的结构体里至少要有起始状态、目标状态、转移字符支持\0表示 εstruct NFAEdge { int from; int to; char input; // \0 表示 ε 转移 }; std::vectorNFAEdge edges; std::vectorint acceptStates;写到这里要提醒一点Thompson 构造比其他建 NFA 的方法繁琐但它的优势是完全机械化的每一步跟着递归走就行不容易出错。你要是试过直接手写 NFA会发现状态编号容易乱边也容易漏。用递归 边集合的方式最终生成的 NFA 结构是确定性的出问题也好排查。3.2 子集构造法NFA 到 DFA 的 ε-closure 与 moveNFA 转 DFA 用的是子集构造法。基本思路是DFA 的每个状态对应 NFA 状态的一个集合两个 NFA 子集如果发出的边和转移目标相同它们就是同一个 DFA 状态。算法核心是两步操作——ε-closure 和 move。ε-closure(T)表示从集合 T 中的状态出发只靠 ε 转移能到达的所有状态的集合。move(T, ch)表示从 T 中的状态出发通过字符 ch 能直接到达的状态集合。DFA 的构建流程是从起始状态的 ε-closure 开始记作 DFA 的起始状态对每个 DFA 状态和每个输入字符计算 move 结果的 ε-closure得到新的 DFA 状态重复直到不产生新状态为止。整个过程用一个队列加一个映射表就能实现// 简化版伪代码 std::vectorstd::vectorint dfaTrans; std::vectorint dfaAccept; std::mapstd::setint, int nfaSetToDfaState; std::queuestd::setint workQueue; std::setint startClosure epsClosure({nfaStart}); nfaSetToDfaState[startClosure] 0; dfaTrans.push_back({}); workQueue.push(startClosure); while (!workQueue.empty()) { auto cur workQueue.front(); workQueue.pop(); int curState nfaSetToDfaState[cur]; if (hasAccept(cur)) dfaAccept.push_back(curState); for (char ch : alphabet) { auto nextSet epsClosure(move(cur, ch)); if (nextSet.empty()) continue; if (nfaSetToDfaState.count(nextSet) 0) { int newId dfaTrans.size(); dfaTrans.push_back({}); nfaSetToDfaState[nextSet] newId; workQueue.push(nextSet); } dfaTrans[curState][chIndex(ch)] nfaSetToDfaState[nextSet]; } }这段代码的精髓就在那个nfaSetToDfaState映射表上。它用std::setint当 key保证相同 NFA 子集只生成一个 DFA 状态。hasAccept(cur)里有一个细节只要 NFA 子集里包含任意一个 NFA 接受状态这个 DFA 状态就是接受状态不是要求所有 NFA 状态都是接受状态很多初学者在这里搞错。另外字符集合alphabet必须提前确定好一般取词法规则中出现过的所有字符否则 move 计算没法遍历完整。3.3 最小化与 dot 输出Hopcroft 划分和 Graphviz 渲染DFA 最小化常用 Hopcroft 算法核心是状态划分先把状态分成接受状态集合和非接受状态集合两个大组然后反复检查每个组里的状态看它们在每个输入字符下的转移目标是否落在同一个组内。如果某个字符导致同一组内的状态跳到了不同组就把这组拆开。重复到所有组都不能再拆每个组就是最小化 DFA 的一个状态。给你一个直观的划分过程示例。假设某 DFA 有 4 个状态接受状态是 {3, 4}初始划分为 P0 { {1, 2}, {3, 4} }。检查输入字符a时状态 1 通过 a 转移到 3状态 2 通过 a 转移到 43 和 4 恰好都在接受组里说明状态 1 和 2 在这个字符下行为一致暂时不需要拆分。但如果状态 1 通过 b 转移到 1而状态 2 通过 b 转移到 3那么它们就会落到不同的组必须拆开。这个过程是个不动点迭代最终得到的每组状态可以合并成一个最小化 DFA 状态。最小化之后输出 dot 文件是让课设可视化的一步。dot 格式本身很简单就是声明节点和边digraph minDFA { rankdirLR; node [shapecircle]; 0 [label0, shapedoublecircle]; 1 [label1]; 2 [label2]; 0 - 1 [labela]; 1 - 2 [labelb]; }其中双圈shapedoublecircle表示接受状态。项目里的 mygraph.cpp 大概率就是解析这类 dot 文本把节点和边读出来画到 Qt 的绘图控件上。解析方法有两种一是写个简单的文本解析器读digraph块里的节点声明和边声明二是直接用 Graphviz 的命令行工具把 dot 渲染成 jpg/png再加载图片。从项目里有dfa.jpg、nfa.jpg、mindfa.jpg这些成品图来看很可能是两条路都走了——预生成图片用于文档展示而 mygraph.cpp 负责交互式绘制。如果用 Qt 绘制而不是渲染图片做法是在 QGraphicsScene 里添加 QGraphicsEllipseItem 表示状态节点添加 QGraphicsLineItem 或 QGraphicsPathItem 表示转移边再叠加 QGraphicsTextItem 标注字符。节点坐标是难点因为 dot 文件里一般没有布局坐标需要自己做层次布局。一个简化方案是把状态按编号排成环形或者横向分层环形布局数学上简单坐标计算就是x cx r * cos(2π * i / n)和y cy r * sin(2π * i / n)实际效果足够课设展示。如果想让图更专业可以调用 Graphviz 的 layout 接口如dot -Tplain输出节点坐标再渲染但那个工程量就大了需要自行权衡。4. Qt 界面与工程配置把状态机跑成可视化工具4.1 mainwindow.ui 与信号槽界面如何驱动分析流程这个项目在界面上不是随便糊一个文本框而是把输入、分析、结果展示、图形可视化分区域组织。mainwindow.ui 用 Qt Designer 布局常见的结构是左上方 QTextEdit 作为源代码输入区一个开始分析的 QPushButton 作为触发按钮中间 QTableWidget 或 QPlainTextEdit 展示 token 序列右侧或下方一个 QGraphicsView 展示当前选中的 DFA / NFA 图。按钮触发分析的信号槽连接是 Qt 开发的标准动作connect(ui-btnAnalyze, QPushButton::clicked, this, MainWindow::onAnalyzeClicked);槽函数onAnalyzeClicked里执行三步取输入文本、调用词法分析器生成 token 流、把结果刷新到界面控件上。这里有一个工程性的建议分析过程如果只涉及几千字符的源码文件放 UI 线程里问题不大但如果你要分析一个几 MB 的源码文件必须在工作线程里跑词法分析然后通过信号把结果回传到主线程刷新界面。否则界面会直接卡死看起来像程序崩溃。后面避坑章节我会专门讲这件事。4.2 QGraphicsView 显示 DFA边与节点的绘制方案mygraph.cpp 和 mygraph.h 这两个文件承担了图形化展示功能。把 dot 数据转换成 Qt 图形涉及到 QGraphicsScene 的构建和自定义 QGraphicsItem 的使用。一个相对完整的流程是void MyGraphView::loadGraph(const QString dotText) { scene-clear(); auto graph parseDotText(dotText); // 解析节点和边 double radius 220; QMapint, QPointF posMap; for (int i 0; i graph.nodes.size(); i) { double angle 2 * M_PI * i / graph.nodes.size(); QPointF pos(graph.centerX radius * cos(angle), graph.centerY radius * sin(angle)); posMap[graph.nodes[i].id] pos; auto* ellipse scene-addEllipse(pos.x() - 18, pos.y() - 18, 36, 36, QPen(Qt::blue)); auto* text scene-addText(QString::number(graph.nodes[i].id)); text-setPos(pos.x() - 8, pos.y() - 12); } // 遍历边画箭头线 for (const auto edge : graph.edges) { QLineF line(posMap[edge.from], posMap[edge.to]); scene-addLine(line, QPen(Qt::darkGray, 2)); } view-setScene(scene); }这段代码有几个值得关注的点。parseDotText是自定义解析函数核心是正则提取数字 - 数字 [label字符]这样的模式可以用QRegularExpression实现。posMap保存每个状态节点的坐标保证后续画边时能拿到两端的准确位置。环形布局虽然简单但当状态数超过十几个时节点和边的重叠会非常严重这时你可以在addLine之前判断一下两个节点之间的直线距离如果太近就略过这条边或者把字体调小一点。真实课设里状态数一般不超过 20 个环形布局完全够用。4.3 两种构建方式qmake 与 CMake 的切换项目里既有lexical.pro又有cmake-build-debug/CMakeFiles说明工程同时适配 Qt Creator 的 qmake 和 CLion 的 CMake 两种构建路径。这是很多 Qt 项目的常规操作主工程文件给 Qt CreatorCMakeLists 给 CLion。lexical.pro的典型内容长这样QT core gui widgets TARGET lexical TEMPLATE app SOURCES main.cpp mainwindow.cpp lex.cpp lexical.cpp mygraph.cpp HEADERS mainwindow.h lex.h mygraph.h RESOURCES imges.qrc dots.qrcQT core gui widgets这一行里 widgets 很关键你是图形界面程序就必须有它只写 core gui 编译出来会是一个没有控件库的控制台项目跑起来一连接口就可能报找不到组件。RESOURCES指向.qrc文件qrc 里面注册的是资源路径映射比如把images/dfa.jpg映射成:/images/dfa.jpg代码里加载图片时用QPixmap(:/images/dfa.jpg)就能取到它会被编译进可执行文件的资源段里发布时不需要单独带着 jpg 文件走。如果在 CLion 里用 CMake 构建CMakeLists.txt要显式调用 Qt 的包查找命令cmake_minimum_required(VERSION 3.16) project(lexical) find_package(Qt5 COMPONENTS Core Gui Widgets REQUIRED) add_executable(lexical main.cpp mainwindow.cpp lex.cpp lexical.cpp mygraph.cpp ) target_link_libraries(lexical Qt5::Core Qt5::Gui Qt5::Widgets )这里最容易翻车的是find_package阶段找不到 Qt 安装路径。解决方式是定义CMAKE_PREFIX_PATH环境变量指向你的 Qt 安装根目录比如 Windows 下的C:/Qt/5.15.2/mingw81_64。CLion 的 CMake 配置页面里加一行 CMAKE_PREFIX_PATH 就行不用改代码。先找到 Qt 库后面编译链接才有戏大部分 Qt 项目编译失败都是这一层没有配好跟代码本身没关系。5. 实战避坑Qt 版本、构建路径与自动机算法的五个典型问题5.1 现象CLion 里 CMake 构建报 cannot mix incompatible Qt libraryCLion 下用 CMake 构建项目链接阶段报fatal: cannot mix incompatible Qt library (version ex50601) with this library。我当时见到这个第一反应是 Qt 库文件混了但检查发现根本不是。原因是你系统里同时装了两个 Qt 版本CMake 的缓存里缓存了某个版本而你客户端编译用的编译器是另一套 ABI导致 CMake 找到的库和你实际链接的库不一致。解决方式删除cmake-build-debug目录下的CMakeCache.txt和整个 CMakeFiles 缓存重新配置。另外确认 CMakeLists 里的find_package指定的版本与你安装的一致比如统一到 5.15.2 就用find_package(Qt5 5.15 COMPONENTS ...)。从那以后我每次切换 Qt 版本都强制删掉整个构建目录重新跑一遍 cmake不跟缓存讲道理。5.2 现象运行时报 could not find the Qt platform plugin linuxfb同样一套代码在开发机上跑得好好的交叉编译到嵌入式板子上就报qt.qpa.plugin: could not find the Qt platform plugin linuxfb。这是典型的平台插件缺失问题。Qt 的 GUI 程序启动时要加载 platform plugin 来决定怎么跟显示系统对接你的程序发布包里没把platforms目录拷过去就找不到插件。解决方式在可执行文件旁建一个platforms目录把 Qt 安装目录里对应编译器的plugins/platforms下的libqlinuxfb.so或qwindows.dll拷进去然后在代码里用QApplication::setLibraryPaths指定插件搜索路径或者直接把插件目录放在 Qt 的库搜索路径里。树莓派交叉编译 Qt 时这个问题出现频率极高优先级排在所有运行问题前面。5.3 现象NFA 转 DFA 时死循环程序不结束也不报错子集构造法实现时最容易死循环现象是程序运行后 CPU 占满界面转圈但终端没有输出任何错误。原因基本出在 ε-closure 的实现上计算 ε 闭包时用的是递归或者 BFS但没有标记已经访问过的状态遇到 NFA 中存在 ε 环比如用 Thompson 构造a*时一定会出现 ε 环就无限递归下去。解决方式闭包函数里加一个 visited 数组每个状态只入队一次std::setint epsClosure(std::setint states) { std::setint closure states; std::queueint q; std::setint visited states; for (int s : states) q.push(s); while (!q.empty()) { int cur q.front(); q.pop(); for (auto edge : edges) { if (edge.from cur edge.input \0) { if (visited.find(edge.to) visited.end()) { visited.insert(edge.to); closure.insert(edge.to); q.push(edge.to); } } } } return closure; }核心就一句话visited 集合必须包含初始状态且每次插入状态时同步入队绝不能让同一个状态被处理两次。这个坑我踩过之后每次写 BFS 类算法都先检查 visited 标记是不是能覆盖所有入队时机多花半分钟省一小时。5.4 现象分析大文件时界面卡死拖动窗口都跟不上词法分析器本身没问题分析一个几百 KB 的源码文件时界面完全冻住。原因是所有分析逻辑都跑在 UI 线程里Qt 的事件循环被占满控件就没办法响应重绘和鼠标事件。解决方式把词法分析放到QThread工作线程里分析完成后用信号把 Token 列表传回主线程更新界面。注意点在于 Token 列表这类自定义类型需要通过qRegisterMetaTypeToken()注册否则queued connection传参时 Qt 不认识这个类型会直接报错。写 Qt 界面程序凡是遇到超过几百毫秒的计算任务都应该默认丢到工作线程里这是 Qt 开发的基本素养。5.5 现象改了一个正则规则所有状态编号全乱图也乱了你在正则表达式里增加一个新的关键字或者运算符重新生成了 NFA 和 DFA但 nfa.dot、dfa.dot、mindfa.dot 三张图全部乱套节点编号对不上。原因是你没有把 NFA 构造、DFA 转换、最小化、dot 输出这几步串成一个流水线而是手工去改 dot 文件里的状态编号。解决方式写一个自动生成脚本从规则定义开始依次生成 NFA 状态集、DFA 状态集、最小化状态集最后一次性输出三张 dot。确保每一步的编号都是程序生成的不要手改。这个过程中我最大的体会是状态编号本质上是自动机内部细节你越想去动它就越容易乱把它完全交给程序是唯一正确做法。6. 用差分验证最小化一份测试集把 DFA 和 minDFA 钉死在等价关系上6.1 构造最小覆盖测试集拿到项目之后建议先不要直接看源码而是先造一批测试用例。写一个tests.txt每行是一条能够覆盖某类词法规则的程序片段比如标识符、整数、浮点数、运算符组合、注释、非法字符。行数控制在 20 条左右覆盖这三类正确识别的输入、边界输入空输入、单个字符输入、非法输入包含、#等未定义字符。然后把这份测试集分别喂给原始 DFA 和最小化后的 DFA比对两者的接受状态。你不需要手动做可以用一个循环程序来跑bool runDfa(const std::string input, const std::vectorint acceptStates, const std::vectorstd::vectorint transTable) { int state 0; for (char ch : input) { int col charClass(ch); if (transTable[state].size() col) return false; state transTable[state][col]; } return std::find(acceptStates.begin(), acceptStates.end(), state) ! acceptStates.end(); }6.2 批量回归与随机字符串反驳很多课设做到最小化 DFA 这一步就停了觉得算法跑通就算完。但最小化是否正确恰恰是最容易出错却最难发现的。一个更有效的做法是生成随机字符串集合对每个字符串分别跑原 DFA 和最小化 DFA只要有一条路径接受状态不一致就说明最小化过程中状态划分有误。随机字符串生成时要控制字符集大小比如只从{a, b, c, 0, 1}里取长度从 1 到 8 递增每组生成几百个。两套 DFA 行为等价才能算通过。如果发现不一致把那条输入打印出来手工检查状态划分到哪一步出的问题。这个方法本质是差分测试效果远好于你盯着状态表看半天。从那以后我每次改词法规则都强制走一遍重新生成 NFA → DFA → 最小化 → 随机差分验证全流程一次图省事跳过验证后来改出的 bug 让我花了两小时才定位回来。希望帮到你。本文还有配套的精品资源点击获取
返回列表