Memory Reallocation when Parsing CSV Files
回顾上篇实现,可见会遇到内存重新分配问题,大文件读取存在隐性开销。
using result_type = std::vector<std::vector<std::string>>;
auto read_csv(std::string_view file, std::string_view type = "", std::string_view delimiter = ",")
-> std::optional<result_type>
{
std::ifstream data_file(file.data());
if (!data_file.is_open())
{
return {};
}
std::string line;
std::getline(data_file, line); // skip the title
result_type result;
while (std::getline(data_file, line))
{
auto tokens = line
| std::views::split(delimiter)
| std::views::transform([](auto&& token) {
return std::string_view(&*token.begin(), std::ranges::distance(token));
});
auto it = std::ranges::begin(tokens);
std::ranges::advance(it, 2);
if (type.empty() || *it == type)
{
// save all records or filtered records.
result.push_back(result_type::value_type(tokens.begin(), tokens.end()));
}
}
return result;
}
之所以存在该问题,主要是因为使用 std::vector
保存结果时,元素动态增长,已有内存渐匮,它便会分配更大的连续内存,并为数据挪窝。对于大文件,可能会发生多次挪窝行为,性能骤降。
深层问题在于,读取文件前,缺少一种常数复杂度的方法,能够提前得到文件行数。若是能够提前知道文件行数,便可以利用 std::vector::reserve
一次性分配所需内存,避免重新分配。
这种问题,三种解决方式较为常见。
第一种方式,二阶段读。读取文件之前,先读取一遍,以获取文件行数。C++ 常见的计算文件行数方式如下:
// Count lines of a file line by line
std::ifstream data_file(file.data());
int lines = 0;
std::string line;
while ( std::getline(data_file, line) )
++lines;
// Count lines of a file using iterator
std::ifstream data_file(file.data());
auto lines = std::ranges::count(std::istreambuf_iterator<char>(data_file), std::istreambuf_iterator<char>(), '\n');
std::cout << "Number of lines: " << lines << '\n';
两种方式都较为常用,不过迭代的方式按 ‘\n'
统计行数,若是文件并不按照其作为换行符,可能会统计出错。
既已知晓行数,便可以为 std::vector
预分配内存,避免重新分配。但是,这种方式将开销转移到了文件读取,首次迭代仅为获取文件行数,未免奢侈,文件过大时,开销亦不低。
第二种方式,便是当前的实现,不管大小,将开销转移到重新分配。缺点如前文所说,不再赘述。
第三种方式,先使用其他非连续内存容器替代 std::vector
,再转换成连续内存容器。
比如,采用 std::list
来保存结果,它的插入性能很好,内存并不要求连续,也不会发生挪窝行为,避免了开销。
这种方式的问题在于,它将开销转移到了类型转换。虽然 std::list
的插入开销很低,但却无法快速随机访问,纵使不再转换为 std::vector
或其他数据结构,开销也会被转移到用户方。
既然开销已经转移到用户方,那又何必多此一举地保存结果呢?直接将思路由整体变为局部,处理一条记录,便交由用户做进一步处理。
这种方式实现如下:
template <class F>
auto read_csv(std::string_view file, F fn, std::string_view delimiter = ",") -> void
{
std::ifstream data_file(file.data());
if (!data_file.is_open())
return;
std::string line;
std::getline(data_file, line); // skip the title
while (std::getline(data_file, line))
{
auto tokens = line
| std::views::split(delimiter)
| std::views::transform([](auto&& token) {
return std::string_view(&*token.begin(), std::ranges::distance(token));
});
std::invoke(fn, tokens);
}
}
//
int main() {
csvpp::read_csv("../data/chip_dataset.csv", [](auto&& tokens) {
fmt::print("{}\n", tokens);
});
}
处理方不再保存结果,用户方若想保存,便须自己设法解决以上问题。
另有一些思路,比如预估文件行数,只扫描部分文件数据,根据这些数据的行数,估计整个文件的行数,这种情况下无需精确到位,只需大致准确,向上取二次方整,便可避免重新分配。
纵观这些方法,可见多数时候,问题并不存在一个完美的解决方案。许多问题的一面是另一个问题的另一面,解决一个问题必然会带来另一个问题。因此,必须分清主次,把主要模块的问题潜移默化地转移到其他次要模块之中,先解决当下的问题。问题其实一直都未消失,只是在模块间滚来滚去。若要真正解决问题,就得从外部借力,增加新东西,以增量来解决旧的问题,随之而来的将会是新的问题,循环往复。
易用性和性能往往也难以兼备,以易用性为主,性能可能会大打折扣,反之易用性又难以顾及。是以这部分会被抽象出去,定制不同实现,转移问题,至于主次如何划分,则应具体问题具体分析了。
若要测试本节代码,可以直接:
git clone https://github.com/lkimuk/csvpp.git
mkdir build && cd build
cmake ../
make
./test
有其他优化思路,可以评论指出。