4.1 逗号分隔的值
逗号分隔的值(comma-separated value),或CSV,是个术语,指的是一种用于表示表格数据的自然形式,使用很广泛。表格的每行是一个正文行,行中不同的数据域使用逗号进行分隔,书中给出的例子是:
这些格式通常由某些程序进行读写,而让程序帮我们做到这些工作,最重要的是自动化,在没有人工干预的情况下提取所需的数据,并将他们转换为人们所需要的形式。这些,都是机器应该做的事情。
在我们有了数据(例如CSV)之后,我们希望能对它做进一步处理。如果此时能有一个方便的库,完成数据格式的转入转出、有着例如数值转换的辅助功能,那就再好不过了。但是我们不知道哪里有这么一个处理CSV的库(或者当我们不知道),因此只能自己去写一个。
在下面几节里,我们要写出这个库的几个版本,其功能是读CSV数据,将它转换为内部表示。库是需要与其他软件一起工作的软件,在这个工作中,我们要讨论一些在设计这类软件时经常遇到的问题。例如,由于没有CSV的标准定义,它的设计不能是基于精确规范进行的,这也是界面设计时经常面临的情况。
4.2 一个原型库
我们不大可能在第一次设计函数库或者界面时就做得很好。往往会发生的事情是当你构造和使用程序的一个版本的时候,才能对如何把系统设计正确有足够的认识。
基于这种理解,我们在构建CSV库时的原则是:先搞出一个原型,忽略很多完备的工程库涉及到的难点。但同时要做到完整和有用,以便能帮助我们熟悉问题。
由于CSV数据太复杂,不可能简单地用函数scanf做输入剖析,我们使用了C标准库函数strtok。对strtok(p, s)的调用将返回p中的一个标识符的指针,标识符完全由不在s中的字符构成。strtok将原串里跟在这个标识符之后的字符用空字符覆盖掉,用这种方式表示标识符的结束。在第一次调用时,strtok的第一个参数应该是原来的字符串,随后的调用都应该用NULL作为第一个参数,指明这次扫描应该从前次调用结束的地方继续下去。这是一个很糟糕的界面,在函数的不同调用之间,strtok需要在某个隐秘处所存放一个变量。这样,同时激活的调用序列就只能有一个,如果有多个无关的调用交替进行,它们之间必定会互相干扰。
for (q = buf; (p = strtok(q, ",\n\r")) != NULL; q = NULL)
{
}
当我们执行这部分代码时,我们可以得到
我们发现,引号没啥用,就把它去掉。函数unquote用于处理引号,它并不能处理嵌套引号的问题。但对于原型而言,这样做已足够了。
if (p[0] == '"')
{
if (p[strlen(p) - 1] == '"')
{
p[strlen(p) - 1] = '\0';
}
p++;
}
当经过一个简单的测试后,我们得到:
field[0] = LU
field[1] = 86.25
field[2] = 11/4/2018
field[3] = 2:19PM
field[4] = +4.0625
看起来还行,但是当我们使用一些稍微变一点的数据
"LU",86.25,"11/4/2018","2:19PM",+4.0625,
52,1kl
仅仅是加了一行数据而已,这个原型就宣告gg了。因此,如果在使用其他来源的数据时发现了程序里的大错误,我们一点都不应该对此感到惊奇。长的输入行、很多的数据域以及未预料到的或者欠缺的分隔符都可能造成大麻烦。这个脆弱的原型作为个人使用而言可能还勉强,或者可以用来说明这种方法的可行性,但绝不可能有更多的意义。在着手开始下一个实现之前,我们需要重新认真地想一想,到底应该如何做这个设计。
现在这个原型里包含着我们的许多决定,有些是明显的,也有些是隐含的。下面列出的是前面做过的一些选择,对一个通用库而言,它们并不都是最好的选择。实际上,每个选择都提出了一个问题,需要进一步仔细考虑。
• 原型没有处理特别长的行、很多的域。遇到这些情况时它可能给出错误结果甚至垮台,因为它没有检查溢出,在出现错误时也没有返回某种合理的值。
• 这里假定输入是由换行字符结尾的行组成。
• 数据域由逗号分隔,数据域前后的引号将被去除,但没有考虑嵌套的引号或逗号。
• 输入行没有保留,在构造数据域的过程中将它覆盖掉了。
• 在从一行输入转到另一行时没有保留任何数据。如果需要记录什么东西,那么就必须做一个拷贝。
• 对数据域的访问是通过全局变量 (数组field)进行的。这个数组由csvgetline与调用它的函数共享。这里对数据域内容或指针的访问都没有任何控制。对于超出最后一个域的访问也没有任何防御措施。
• 使用了全局变量,这就使得这个设计不能适合多线程环境,甚至也不允许两个交替进行的调用序列。
• 调用库的程序必须显式地打开和关闭文件,csvgetline做的只是从已经打开的文件读入数据。
• 输入和划分操作纠缠在一起:每个调用读入一行并把它切分为一些域,不管实际应用中是否真的需要后一个服务。
• 函数返回值表示一个输入行中的数据域个数,每行都被切分,以便得到这个数值。这里也没有办法把出现错误和文件结束区分开。
• 除了更改代码外,没有任何办法来改变这些特性
以上所列的并不完全,它说明了许多可能的设计选择,各个决定都已经被编织到代码里。对于一个急迫的工作,这样做还说得过去,例如要剖析从一个已知信息源来的固定格式的东西。但是,如果格式可能有变化,例如逗号出现在引号括起的串内,或者服务器产生很长的行或很多的域,那么又会怎么样呢?
这些具体东西看起来都好对付,因为这个“库”很小,而且只是一个原型。但是,如果设想一下,当这个库出台几个月或者几年以后,它不可能变成某些大系统的一部分,而系统的规范又在随着时间的推移不断变化,csvgetline能怎样适应这些情况吗?如果这个程序是提供给别人用的,在原始设计中的这些仓促选择引起的麻烦就可能到许多年后才浮现出来。这正是许多不良界面的历史画卷。事实确实非常令人沮丧,许多仓促而就的肮脏代码最后变成广泛使用的软件,在那里它们仍然是肮脏的,而且常常也达不到它们应有的速度。
糟糕的源码:
#include "stdafx.h"
#include "string.h"
#include <iostream>
using namespace std;
char buf[200];
char *field[20];
int csvgetline(FILE *fin);
char *unquote(char *p);
int main()
{
FILE *fp;
int num;
fp = fopen("..\\test2.txt", "r");
if (fp == NULL)
{
printf("Error in opening initial file");
return -1;
}
num = csvgetline(fp);
for (int i = 0; i < num; i++)
{
cout << "field" << "[" << i << "]" << " = " << field[i] << endl;
}
return 0;
}
/*sample:"LU",86.25,"11/4/2018","2:19PM",+4.0625*/
int csvgetline(FILE *fin)
{
int nfield;
char *p, *q;
if (fgets(buf, sizeof(buf), fin) == NULL)
{
return -1;
}
nfield = 0;
/*在连续调用分隔同一个字符串时,第一次分隔使用字符串头地址*/
/*第二次使用NULL*/
/*第二次还使用字符串名称将导致分隔失败*/
/*q置为NULL的原因*/
/*第二个输入中每个字符都会被当作分隔符*/
for (q = buf; (p = strtok(q, ",\n\r")) != NULL; q = NULL)
{
field[nfield++] = unquote(p);
//printf("%s", p);
}
return nfield;
}
char *unquote(char *p)
{
if (p[0] == '"')
{
if (p[strlen(p) - 1] == '"')
{
p[strlen(p) - 1] = '\0';
}
p++;
}
return p;
}