被误解的C++——C(++)
C(++)
今天所要探讨的,是C++中最大的误解:C++的使用。对C++使用的误解是大量其他误解的根源。为此,我找了个真实的案例,来展示C++的两种不同使用方法。相信诸位看完之后,便会明白这层误解的巨大影响。
我差不多可以算是一个C出身的C++程序员。尽管前后也就用了一年多点的C,然后就转向了C++。但是,在长达9年的时间里,我实际上还是在用C编程,在C++里用C编程。
大约一年多以前,一个偶然的机会,我拜读了D&E(以及其他C++大师的著作)。然后对自己的C++使用进行了反思。此后,我便认真地按照大师们的教诲,编写我的代码。正巧,我过去编写的一个软件需要升级重写。我也正好利用这个机会全面地实践一下真正的C++编程。我节选了其中的一个组件,作为案例,展示不同时代的C++。
这个组件的功能很简单,将一组二进制序列转换成N进制的字符串,以及反过来转换。当时的需求是转换成32进制,这样26个字母加上10个数字,扣掉容易混淆的字符,正好可以有32个。但是为了将来的扩展,我还是把它做成了任意进制和字符集的转换,(只要有足够的字符可用)。
我选择了一个比较通用的算法:把二进制序列看作是一个“超级整数”,用进制数N反复除这个“超级整数”,直到所得的商为0。每次除得到的余数,构成一个序列,便是编码的序列,但在使用前还需要将其反序。然后再用序列的每一个数字在字符表中索引,获得最后的编码字符串。解码的操作是编码的逆操作,只需把这个过程反过来,除法变乘法即可。为了节省篇幅,我只考察编码,毕竟我们的目的还是比较C++不同的用法,而不是研究编码问题。
让我们先完整地看我早先的做法,然后再看后来的做法。
首先,我编写了一个函数,LongDiv(),用来执行“超级整数”的除法:
unsigned char LongDiv(unsigned char* dividend, int n, unsigned char divisor, unsigned char* result, unsigned char *eigenvalue=NULL)
{
if(dividend==NULL || result==NULL || n < 0)
return -1;
long op=0;
unsigned char m=0;
unsigned char e=0;
for(int i=(n-1); i>=0; i--)
{
op+=dividend[i];
result[i]=op/divisor;
m=op%divisor;
op=m;
op<<=(szT1*8);
e|=result[i];
}
if(eigenvalue!=NULL)
*eigenvalue=e;
return m;
}
被除数是一个序列,用指针dividend传递,序列的个数用n传递,divisor是除数,结果放在指针result所指定的内存中,eigenvalue是指向特征值的指针,函数返回余数。
我之所以没有重载操作符/是为了能够在除的同时,获得余数和一个特征值。这个特征值表明了在除的过程中,二进制序列的每个数值的商是否都为0。如果都为0,整个序列的商就为0,编码也就完成了。
然后,我编写了编码的核心函数:
int EncodeAll(int N, unsigned char* data, int ndata, unsigned char* sqs)
{
if(data==NULL || ndata<0)
return -1;
DataT mx=-1;
double m=mx; m++;
double dres=(ndata*::log(m))/::log((double)N);
int nres=dres, ne=log(m)/::log((double)N);
nres+=((dres-nres)==0) ? 0 : 1;
if(sqs==NULL)
return nres;
DataT e=1;
int i=0;
for(; e!=0;)
{
sqs[i]=::LongDiv(data, ndata, N, data, &e);
i++;
}
Reverse(sqs, i, sqs);
return i;
}
参数N表示进制数,data是指向二进制序列的指针,ndata是二进制序列的长度,sqs是指向用于存放结果的数据缓存区的指针。
这个函数的核心算法非常简单,只有6行,从for开始,到Reverse()调用。前面那一大堆恼人的东西,纯粹是一组“安全围栏”。这里需要仔细分析,让我们一段段来。
if(data==NULL || ndata<0)
return -1;
这是传入参数的有效性检验,为了防止无效的引用。但是这种检验并不总是有效,因为传输的参数完全可以是一个非NULL值,但却是一个无效的引用(象Win32中著名的0xcccccccc)。这种检验的有效性依赖于整体编程中的一个习惯,就是将所有的指针,在声明后立刻赋NULL。我通常都这样做,安全第一。
DataT mx=-1;
double m=mx; m++;
double dres=(ndata*::log(m))/::log((double)N);
int nres=dres, ne=log(m)/::log((double)N);
nres+=((dres-nres)==0) ? 0 : 1;
这是我最痛恨的一段代码,目的是为了计算出一个长度为ndata的序列,编码后需要多长的数据缓冲区。(这段代码让我把中学代数的指数部分好好地复习了一遍)。
if(sqs==NULL)
return nres;
如果sqs指针的值为NULL(空指针),那么返回所需的数据缓冲区的长度,不执行具体操作。否则,执行数据编码操作。这个手法是从一些C API库中学来的,为了让算法能够适应变长的数组。过会儿我们会看到这个手法是如何工作的。
下一步,需要将编码后的数字序列转换成字符。所以,我作了一个字符表:
class CCharMap
{
public:
CCharMap(const char *CharSet)
{
if(CharSet==NULL)
{
m_nDtoC=0;
m_aDtoC=NULL;
}
else
{
m_nDtoC=::strlen(CharSet);
m_aDtoC=new char[m_nDtoC+1];
::memcpy(m_aDtoC, CharSet, m_nDtoC);
m_aDtoC[m_nDtoC]='\0';
}
::memset(m_aCtoD, 0, ASSICSETSIZE*sizeof(int));
InitCharMap();
}
~CCharMap()
{
delete m_aDtoC;
}
protected:
char *m_aDtoC;
int m_nDtoC;
int m_aCtoD[ASSICSETSIZE];
protected:
void InitCharMap()
{
for(int i=0; i<m_nDtoC; i++)
{
m_aCtoD[m_aDtoC[i]]=i;
}
}
public:
char Data2Char(int data)
{
if(data>m_nDtoC || data<0 || m_aDtoC==0)
return '\0';
return m_aDtoC[data];
}
int Char2Data(char chr)
{
return m_aCtoD[chr];
}
};
从数值到字符的转换,我利用了一个动态分配的数组m_aDtoC(严格地讲,它不是数组)。而相反的转换,我使用一个真正的C数组m_aCtoD[],它的大小是256(#define ASSICSETSIZE 256)。所以,我的这个字符表只能应付ASSIC字符集,无法扩展。在我当时的需求情况下,这足够了。
最后,我用一个类封装了整个编码器:
class CEncoder : public CCharMap
{
public:
CEncoder(const char* CharSet)
: CCharMap(CharSet)
{}
public:
int Data2Text(unsigned char* data, int size, char* text)
{
if(!IsReady())
return -1;
if(text==NULL)
return ::EncodeAll(data, size, NULL);
unsigned char *pdata=new unsigned char[size];
::memcpy(pdata, data, size*sizeof(unsigned char));
int n=::EncodeAll(pdata, size, text);
delete pdata;
for(int i=0; i<n; i++)
{
text[i]=Data2Char(text[i]);
}
return n;
}
int Text2Data(char* text, int size, unsigned char* data)
{…}
};
Data2Text()成员负责编码,Text2Data()负责解码。Data2Text()的参数data是指向二进制序列的指针,size是二进制序列的大小,text是指向存放编码字符串的内存缓冲区的指针。这个函数做了这么些事情:
if(text==NULL)
return ::EncodeAll(data, size, NULL);
当text为NULL时,调用::EncodeAll()。注意,当这样调用时,EncodeAll()将会返回转换后编码序列的长度,也就是编码字符串的长度。
unsigned char *pdata=new unsigned char[size];
::memcpy(pdata, data, size*sizeof(unsigned char));
int n=::EncodeAll(pdata, size, text);
delete pdata;
创建动态数组,制作二进制序列的副本,因为EncodeAll()算法会修改这个序列。最后,释放数组。
for(int i=0; i<n; i++)
{
text[i]=Data2Char(text[i]);
}
把编码序列中的数值转换成字符。
至此,编码器基本上完成了。当然,在实际的代码中,还需要一些辅助的内容,因为和主题无关,我们就不管它们了。
使用起来会是这样的:
CEncoder e(“023456789ABCDEFHIJKLNPQRSTUVWXYZ”);
unsigned char *data=NULL;
int ndata;
//初始化data序列和ndata
int n=e.Data2Text(data, ndata, NULL);
char* text=char[n];
e.Data2Text(data, ndata, text);
delete text;
在这个早期版本的编码器中,我秉承了一贯的C风格。我们可以看到大量的原生指针、不安全的内存分配,以及烦人的动态内存控制。编码器的实现和使用代码,都是这样。