在日常开发中,我们经常需要从文件中读取表格数据,比如学生成绩单、商品清单或系统配置表。遇到这类需求时,最简单、最通用的文件格式就是 CSV。
初学的小伙伴不要把 CSV 文件和 Excel 的 .xlsx 工作簿混为一谈。CSV 本质上仍然是普通的纯文本文件,每一行内部的多个数据之间约定使用英文逗号(,)隔开。只要把前面学过的逐行读取文件与按逗号拆分结合起来,我们就能把表格中的数据提取出来。
CSV 文件是什么?
CSV 是 “Comma-Separated Values” 的缩写,意思是 “逗号分隔值”,文件后缀名通常为 .csv。在标准的简单 CSV 文件中,每一行表示一条完整的数据记录,同一行里的多个字段之间使用英文逗号(,)隔开。
比如下面这份 students.csv 文件,保存了 3 名学生的学号、姓名和成绩:
id,name,score
1,Jack,95
2,Lucy,88
3,Tom,92在上面这份数据中,第 1 行 id,name,score 是表头,用来说明每一列分别代表什么含义。从第 2 行开始,后面的 3 行才是真正的学生数据。如果用表格展示,它对应的结构如下表所示。
| id(学号) | name(姓名) | score(成绩) |
|---|---|---|
| 1 | Jack | 95 |
| 2 | Lucy | 88 |
| 3 | Tom | 92 |
可以看到,CSV 文件的规则非常直观:换行符用来区分不同的 “行”,英文逗号用来区分同一行里的不同 “列”。只要把握住这一特点,我们就能在 C++ 中把 CSV 文件的数据读取出来。
提示 并不是所有的 CSV 文件都包含表头。如果某些 CSV 文件从第 1 行开始就是正式数据,那么我们在读取时就不需要跳过第 1 行。
拆分单行 CSV 数据
在 C++ 中读取并处理一份简单的 CSV 文件,通常按顺序分为以下 4 步:
- 按行读取:调用
std::getline()从文件中一行一行读取文本。 - 拆分字段:按英文逗号(,)把读到的一整行字符串切分成多个独立的字段。
- 类型转换:将需要参与数值比较或计算的字段,从字符串转换为整数或浮点数。
- 处理数据:在程序中打印、统计或保存提取出来的数据。
在这 4 个步骤中,最关键的是第 2 步 “拆分字段”。由于 std::string 没有内置现成的分割函数,在实际开发中常用 2 种方式来拆分单行 CSV 数据:一种是借助 std::stringstream 配合 std::getline(),另一种是调用字符串的 find() 与 substr()。
对于初学的小伙伴来说,我们重点掌握第 1 种方式就足够了,它的代码更简短,也不容易算错下标。
1. 使用 stringstream 与 getline() 拆分(推荐)
前面使用 std::getline(file, line) 读取文件时,我们只传入了两个参数,它默认以换行符作为结束标记读取一整行。实际上,std::getline() 还支持传入第 3 个参数,用来指定自定义的分隔字符,比如英文逗号 ','。
不过 std::getline() 只能从 “流” 中读取数据,不能直接操作普通的 std::string 字符串。这时我们可以借助 <sstream> 头文件中的 std::stringstream 把整行字符串包装成字符串流对象,再交给 std::getline() 按逗号依次提取字段。
语法:
std::stringstream ss(line);
std::string field;
while (std::getline(ss, field, ',')) {
// 处理当前字段
}说明:
std::stringstream ss(line); 表示把整行字符串 line 包装成字符串流 ss(关于字符串流的更多用法,后面章节会详细介绍)。紧接着,std::getline(ss, field, ',') 每次从 ss 中向后读取,遇到英文逗号 ',' 就停下来。它会把逗号前面的内容存入 field 中,并自动跳过逗号本身。
把 std::getline() 直接放进 while 的条件括号里,是因为只要成功读出一个字段,条件判断就为真,循环继续执行。当读到行尾已经没有内容可读时,条件判断变为假,循环就会自动结束。
示例 1:使用 stringstream 拆分字段
#include <iostream>
#include <sstream> // 提供 std::stringstream
#include <string>
int main() {
std::string line = "1,Jack,95";
std::stringstream ss(line);
std::string field;
// 按英文逗号依次读取字段
while (std::getline(ss, field, ',')) {
std::cout << field << '\n';
}
return 0;
}运行结果如下。
1
Jack
95分析:
std::stringstream ss(line); 先把字符串 "1,Jack,95" 包装成字符串流 ss,接着 while 循环会连续读取 3 次。
当我们第 1 次读取时,遇到第 1 个逗号停下,取出 "1"。第 2 次读取到第 2 个逗号前,取出 "Jack"。第 3 次一直读到字符串末尾,取出 "95",整行读完后循环自动退出。
2. 使用 find() 与 substr() 拆分(了解)
除了借助 std::stringstream 之外,我们还可以采用前面学习字符串分割时的思路,直接调用字符串的 find() 和 substr() 成员函数来查找逗号下标并截取字段。下面我们来看一个例子。
示例 2:使用 find() 和 substr() 拆分
#include <iostream>
#include <string>
#include <vector>
// 按逗号查找并截取字段
std::vector<std::string> splitByFind(const std::string& line) {
std::vector<std::string> fields;
std::size_t start = 0;
while (true) {
std::size_t comma = line.find(',', start);
if (comma == std::string::npos) {
fields.push_back(line.substr(start));
break;
}
fields.push_back(line.substr(start, comma - start));
start = comma + 1;
}
return fields;
}
int main() {
std::string line = "1,Jack,95";
std::vector<std::string> fields = splitByFind(line);
for (const auto& item : fields) {
std::cout << item << '\n';
}
return 0;
}运行结果如下。
1
Jack
95分析:
在 splitByFind() 函数中,line.find(',', start) 表示从下标 start 开始向后查找下一个英文逗号的位置。以字符串 "1,Jack,95"(下标为 0~8)为例,每一轮循环拆分的执行过程如下:
- 第 1 次循环:从
start = 0开始,在下标 1 处找到第 1 个逗号,line.substr(0, 1 - 0)截取出"1",接着把起点更新为start = 2跳过该逗号。 - 第 2 次循环:从
start = 2开始,在下标 6 处找到第 2 个逗号,line.substr(2, 6 - 2)截取出长度为 4 的"Jack",再把起点更新为start = 7。 - 第 3 次循环:从
start = 7向后查找,后面已经没有逗号,find()返回std::string::npos,此时执行line.substr(7)截取出末尾的"95"并退出循环。
处理 “行尾空字段” 的差异
当每一列都有内容时,上面两种拆分方式的结果完全一样。但如果某一行的最后一列恰好为空,也就是整行以逗号结尾(例如 "1,Jack,"),两者的表现会有一点细微差异:
stringstream + getline():读完 "Jack" 后面的逗号就已经到达字符串末尾,下一轮 getline() 会直接读取失败并退出循环,因此只会拆出 2 个字段,忽略掉末尾的空字段。
find() + substr():更新 start = comma + 1 后还会再执行一次 line.substr(start),能够把逗号后面的空字符串 "" 保留下来,准确拆出 3 个字段。如果业务数据中最后一列可能为空,我们就应该选用第 2 种方式来处理。
读取并解析完整 CSV 文件
掌握了单行拆分方法后,我们可以把 std::stringstream 的拆分逻辑封装成一个独立的 splitCsvLine() 函数,再配合 std::ifstream 逐行读取完整的 CSV 文件。
这里需要特别注意一点:从 CSV 文件中拆分出来的每个字段,默认都是 std::string 字符串类型,比如 "95" 和 "88"。如果我们直接使用加号 + 把这两个字符串相加,得到的是拼接结果 "9588",而不是数学求和。
因此,在参与数值比较或加减运算前,我们需要调用 <string> 头文件中的 std::stoi() 函数把字符串转为 int 整数,比如 std::stoi("95") 会返回整数 95。这里的 stoi 是 “string to integer(字符串转整数)” 的缩写;如果字段是小数,则可以调用 std::stod() 转为 double 类型。
为了方便小伙伴们直接复制代码运行测试,下面的示例会先使用 std::ofstream 在当前目录下创建一份 students.csv 测试文件,然后再打开它逐行解析并统计总成绩。
示例 3:读取并解析 CSV 文件
#include <fstream>
#include <iostream>
#include <sstream>
#include <string>
#include <vector>
// 按逗号拆分单行 CSV
std::vector<std::string> splitCsvLine(const std::string& line) {
std::vector<std::string> fields;
std::stringstream ss(line);
std::string field;
while (std::getline(ss, field, ',')) {
fields.push_back(field);
}
return fields;
}
int main() {
// 生成测试用的 CSV 文件
std::ofstream outFile("students.csv");
outFile << "id,name,score\n";
outFile << "1,Jack,95\n";
outFile << "2,Lucy,88\n";
outFile << "3,Tom,92\n";
outFile.close();
// 打开并逐行读取 CSV 文件
std::ifstream file("students.csv");
if (!file.is_open()) {
std::cout << "无法打开文件 students.csv\n";
return 1;
}
std::string line;
std::getline(file, line); // 跳过第 1 行表头
int totalScore = 0;
while (std::getline(file, line)) {
if (line.empty()) {
continue;
}
std::vector<std::string> fields = splitCsvLine(line);
if (fields.size() != 3) {
continue;
}
int id = std::stoi(fields[0]);
std::string name = fields[1];
int score = std::stoi(fields[2]);
totalScore += score;
std::cout << "id=" << id << ", name=" << name;
std::cout << ", score=" << score << '\n';
}
std::cout << "总成绩:" << totalScore << '\n';
return 0;
}运行结果如下。
id=1, name=Jack, score=95
id=2, name=Lucy, score=88
id=3, name=Tom, score=92
总成绩:275分析:
在这个例子中,程序开头先创建了测试用的 students.csv 并调用 outFile.close() 保存到磁盘。如果小伙伴们本地目录里已经有现成的 CSV 文件,平时编写读取代码时直接从 std::ifstream file("students.csv"); 开始写就可以了。
在进入 while 循环前,我们先单独执行了一次 std::getline(file, line); 把第 1 行表头读走,这样后面循环里每次处理的就都是正式的学生数据了。拆分出 fields 容器后,我们先通过 fields.size() != 3 检查当前行是否恰好有 3 列,防止遇到格式残缺的行时发生下标越界,最后再调用 std::stoi(fields[2]) 将成绩转为整数累加。
另外要注意,std::stoi() 要求传入的字符串必须是合法的整数格式。如果前面忘记跳过第 1 行表头,直接把列名 "score" 传给 std::stoi(),程序在运行时就会抛出 std::invalid_argument(无效参数)异常而终止。下面这种未跳过表头直接转换的写法就是错误的。
// 错误方式
int badScore = std::stoi("score");复杂 CSV 文件的处理建议
前面这种 “先按行读取、再按逗号切开” 的写法,非常适合处理结构规整的简单 CSV 文件。但如果遇到字段内部本身包含特殊字符的复杂 CSV 数据,直接按逗号切分就会出错,比如下面这两条记录:
id,name,score
1,"Jack, Jr.",95
2,"Lucy ""L""",88从上面可以看到,第 1 条记录的姓名 "Jack, Jr." 内部自带英文逗号,通用 CSV 规范会使用双引号把整个字段括起来,并通过两个连续双引号 "" 表示转义。如果我们继续使用只认逗号的简单拆分函数,第 1 条记录就会拆成错误的 4 列。
因此在实际开发中,我们可以根据数据来源按以下 2 条原则来选择方案:
- 内部可控的简单数据:如果 CSV 文件由我们自己的程序生成,列数固定且不含英文逗号、双引号和换行符,直接采用这一节的代码读取就非常轻便,无需引入第三方库。
- 外部输入的复杂数据:如果 CSV 文件来自用户上传、办公软件导出或外部系统对接,字段中随时可能出现特殊字符,建议我们直接选用
csv-parser、fast-cpp-csv-parser等成熟的第三方 CSV 解析库。
