C++基础知识(修正)

一、指针

指针可以说是双面剑,能让C/C++灵活强大,也让C/C++易出大错,以下是C/C++中指针使用易错的地方。

1、指针的混合运算和运算优先级

指针运算符*、取地址运算符&、++、--运算符优先级都相同,且都具有右结合性。在一个表达式中判断执行顺序,还要结合++、--的前置性(先加减后用)和后置性(先用后加减)统一考虑。

int a[4] = {100, 200, 300, 400}, b; 

int *p = &a[0]; 

对这两行做如下操作:

例:&*p,根据右结合性,求值顺序是:先*后&,即&(*p)=&a=p。

例:b=*p++,由后置++知,先执行b=*p,后执行p++;

例:b=*++p,由前置++知,先执行++p,后执行*p;

例:b=(*p)++,由后置++知,先执行b=*p=a[2],后执行(*p)++=a[2]++;

例:b=*(p++),由后置++知,先执行b=*p,后执行p++;

例:b=++*p,由前置++知和右结合性,先执行b=++(*p)=++a[3],p指向不变;

2、指针与一维数组

指针与数组是天然的关系,通过指针可以很灵活地操纵数组,对于一维数组可以通过多种方式去访问:

int a[5]={0, 1, 2, 3, 4};

int *pa = a; // 数组名是特殊指针,是数组首地址,其不能被更改或赋值

// 用指针遍历数组元素-1

for(pa=a; pa<a+5; pa++)

    cout << *pa << endl;

// 用指针遍历数组元素-2

for(int i=0; i<5; i++)

    cout << *(pa+i) << endl;

// 把数组当成指针来遍历数组元素

for(int i=0; i<5; i++)

    cout<<*(a+i) << endl;

// 把指针当成数组来遍历数组元素

for(int i=0; i<5; i++)

    cout<<p[i] << endl;

说明:pa+i=a+i=&a[i],表示第i个元素的地址。*(p+i)=*(a+i)=p[i]=a[i]均表示第i个元素的值。

3、指针与二维数组

第i行的行地址:     a+i &a[i]

第i行的首地址:     a[i]    *(a+i)    &a[i][0]

元素a[i][j]的地址: a[i]+j  *(a+i)+j   &(a[i][0]+j)    &a[i][j]

第i行j列元素值:    *(a[i]+j)  *(*(a+i)+j)   *(&a[i][0]+j)   a[i][j]

a+i=&a[i]=a[i]=&a[i][0]

注:为了区别数组指针(数组的首地址)与指向一维数组的指针,C++中引入了行址的概念,a+i与&a[i]只能用于指向一维数组的指针变量,而不能用到普通指针变量。

int a[3][3];

int *pa = a+0; // 出错: pa与a+0类型不同,如果将行地址赋给变通指针变量,必须用强制类型转换。

int *pa = (int *)(a+0); // OK!

4、指针数组与指向一维数组的指针

指针数组的定义格式:类型 *指针变量[n]。定义了一个数组,该数组有n个指针变量。

指向一维数组的指针定义格式:类型 (*指针变量)[n],定义了一个指向有n个元素的一维数组指针变量,通常与二维数组中的行地址对应。

5、用指针管理动态分配内存空间

int *p = new int[2] = {0, 1};

for(int i=0; i<2; i++)

    cout << "addr = " << p++ << endl;

delete [] p;

上面程序运行时将出现错误,是因为p++操作后,p不再指向新分配的内存首地址,所以这时候delete会报错!这是不安全的!但改为以下两种方法才是安全的!

cout << "addr = " << p+i << endl;

cout << "addr = " << &p[i] << endl;

另外,指地p本身是一个变量,这个变量是在栈上分配的。在以下程序中:

{

    int *p; 

    p = new int[100];

    ......(1)    

    delete p;

    ......(2)

}......(3)

在(2)到(3)之间,指针p仍然存在,它指向的内存区域也存在,所以用的时候要非常注意,一般delete p后,再p=NULL。如果不这样的话,有可能会遇到三种情况:第一种情况是该内存所在的“内存页”没有任何对象,堆管理器已将其返回给系统,此时通过p访问该内存会引起“访问违例”,导致进程崩溃。第二种情况是该位置处所在的“内存页”还有其他对象,且该位置回收后还没分配,此进通过p访问取的值是无意义的,虽然不会立即引起崩溃,便后续操作不可预测。第三种情况是该位置处成在的“内存页”还有其他对象,该位置被回收后,已被其他对象申请,通过p访问,取值为其他对象的值,虽然不会引起崩溃,但会改变新对象的状态,从而使得创建对象状态莫名其妙地变化。

5、指针与引用

指针是C语言的一种工具,C++中除了继承了指针,还提供了引用。引用所实现的功能指针都可以实现,为什么要引入引用呢?先看一下指针与引用的相同点与区别点。

5.1、指针与引用的相同处:

引用本身也要占用内存空间,存储被引用变量的地址,这和指针变量本身占内存空间及存储指向变量的地址一样。作为函数参数时,都能实现实参与形参的双向传递。

5.2、指针与引用的区别:

普通指针可以被多次赋值,即指向不同的对象地址,而引用只能在初始化时指定被引用对象,其后不可更改,类似于指针常量。指针的地址是可以获得的,而引用本身的地址是被隐藏不可获得的。

5.3、引入引用的目的:

作为数据参数传递、减少大对象的参数传递开销引用可以很好的替代指针,用引用编写的代码更清晰简洁。如下所示:

void swap(int *const pa, int *const pb) { 对*pa、 *pb操作}                                 iint a, b; ... swap (&a, &b);

void swap(int &ra, int &rb){对ra、rb操作}                                                             int a, b; ...swap(a, b);

就使用指针而言还可以避免在函数体内对指针的算术运算所带来不可预测的错误。

5.4、引用所不能实现的场景:

经常要改变所指的对象场景。指针还可以作为空指针,来实现特定含义的场景。使用指针指向函数的场景(函数是没有引用的)。new动态使用内存的场景。以数组形式传递数据的场景。


6、指针的安全性隐患及解决方案

6.1、地址安全性

通常使用的变量,其地址是由编译器分配的,编译器会给出一个有效地址,使访问不会指向不允许的地址或是其它地址。而指针所存储的地址是由程序运行时确定的。如果不是有效地址,会出现安全隐患。

如果一个指针未赋初值就使用,则会造成地址安全性隐患。同样一个变量不赋初值就被使用,也会造成地址安全性隐患。利用指针算术运算的场景一定要限制在指向数组上面,否则会出现不可预测的后果。即使这样也会有越界的危险,而编译器是不会告诉你的,这种错误只会暴露在运行期内。所以尽量不直接通过指针来访问数组,而是用封装的数组,例如vector。

6.2、类型安全性

基本数据类型和类类型都有类型转换,由于这种转换是基于内容的,所以安全,如下所示:                                                                                                         int i = 2; float x = static_cast<float>(i); 编译器将i的整型二进制转为浮点二进制,这就是基于内容的转换。

如果是指针参与,则情况就大不一样了:int i=2; float *p = reinterpret_cast<flaot *>(&i); 其中reinterpret_cast和static_cast都是类型转换操作符,可将一种类型指针转为另一种类型指针。以上操作的结果是浮点型指针p将指向一个整型变量i。通过p访问整形变量i,所执行的操作只能是针对浮点型的!这能不出问题吗?

reinterpret_cast所做的转换是非常底层的,大都数情况下要避免使用它在不同类型指针间转换,相对而言static_cast是安全和确定的。

reinterpret_cast不仅可以在不同类型对象的指针之间转换,还可以在不同类型的函数的指针间、不同类数据成员的指针间、不同类函数成员的指针间、不同类型的引用间相互转换。其过程C++标准没有明确,各编译器间可能会有差异。C++标准中只保证用reinterpret_cast操作符将A类型p转换成B类型q,再用reinterpret_cast将B类型q转换成A类型r后,应当有p==r为真。

static_cast也不是绝对安全,如:int i=2; void *vp = &i; float *p=static_cast<float *>(vp); 则会出现安全隐患。而如果将void转成原来的类型则是安全的。如改成:int *p=static_cast<int *>(vp); 则安全了。

C语言中允许void指针隐含转为其他任何类型的指针,C++中只能显示转换,所以C++更为安全!对于void指针的使用,最好只用在传递上,即传递不同类型的对象,传递完后,转换成最初的类型。

标准C中有很多void指针作为参数与返回值的使用,如memset、memcmp、memcpy、malloc、free等,这些操作是不管类型的,只把不同类型的数据当作无差异的二进制序列!在C++中应该要少用!

const_cast可以将数据类型中的const属性去除!示例:void foo(const int *cp){ int *p = const_cast<int *>(cp); (*p)++;}。 如果滥用会破坏数据的保护。所以它是不安全的。当在某些固定场景使用它是安全的,可以参考[1]文中P247给出的例子。

6.3、堆对象的管理

局部变量是在运行栈上分配空间,由编译器生成代码控制,空间会自行释放,静态生存期变量空间由连接器分配,占用大小固定不变,在运行期无须释放。而new出来的堆对象则必须由程序显示调用delete释放。否则会造成内存泄漏。原则上谁申请谁释放。但涉及到不同类间堆对象转移时则不好控制,可利用第三方共享指针来处理,如boost库中智能指针。


二、排序算法

1、冒泡法

通过多轮规模递减的遍历,在每次遍历中,将最小的或是最大的沉到底部。

for(int i=0; i<N; i++)

{

    for(int j=0; j<N-i-1; j++) // 减少规模,将底排除

    {

        // 相邻对比,将小的沉底,最终结果为降序

        if(a[j] < a[j+1]){int temp=a[j]; a[j]=a[j+1]; a[j+1]=temp;}

    }

}

2、选择法

通过多轮规模递减的遍历,在每次遍历中,将最小的或最大的选择出来放在头部。

for(int i=0; i<N-1; i++)

{

    for(int j=i+1; j<N; j++) // 减少规模,将底排除

    {

        // 与最上面元素对比,将大的交换成最上面的元素,最终结果为降序

        if(a[i] < a[j]){int temp=a[i]; a[i]=a[j]; a[j]=temp;}

    }

}

3、擂台法

是在选择法的基础上改进的,选择法中每次遍历条件成立就交换,效率低,擂台法则在条件成立时交换下标,到该轮结束后再对比交换。

for(int i=0; i<N-1; i++)

{

    int k=i; 

    for(int j=i+1; j<N; j++) // 减少规模,将底排除

    {

        // 与最上面元素对比,将大的交换下标,最终结果为降序

        if(a[k] < a[j]) k=j;

    }

    if(k>i)  {int temp=a[i]; a[i]=a[j]; a[j]=temp;}

    }

}



三、const类型变量

1、变量

1.1、定义格式为:const 类型 变量名=值 

1.2、定义格式为:类型 const 变量名=值;

如:const float value=13.2; float const value=13.2;

以上两种定义格式都是相同的,表示变量的值是不能通过const修饰的变量名改变;

2、指针

2.1、定义格式为:const 类型 *指针变量名 = 地址;

2.2、定义格式为:类型 const *指针变量名 = 地址;

上面两种都一样,表示指向常量的指针,常量的值是不能通过指针变量改变的。

2.3、定义格式为:类型 * const 指针变量名 = 地址;

定义常量指针,指针的地址不能通过指针变量改变,但地址处存放的值可以变。

2.4、定义格式为 const 类型 * const 指针变量名 = 地址;  

指向常量的常量指针,指向的地址不能通过指针变量改变,地址处存放的值也不可以通过指针变量改变。

四、重载函数

4.1、对重载函数不能以返回值作标准的解释:

int add(int x, int y);

float add(int x, int y);

如果程序中出现add(3, 4),编译器将不知道该选择哪一个函数调用!


五、结构体

5.1、结构体大小的计算规则:其实字节对齐的细节和具体编译器实现相,但

一般而言,满足三个准则:

(1). 结构体变量的首地址能够被其最宽基本类型成员的大小所整除;

(2). 结构体每个成员相对于结构体首地址的偏移量都是成员大小的整数倍,如

有需要编译器会在成员之间加上填充字节;

(3). 结构体的总大小为结构体最宽基本类型成员大小的整数倍,如有需要编译

器会在最末一个成员之后加上填充字节;

这主要是从效率角度来处理的,例如一个int型(假设为32位系统)如果存放

在偶地址开始的地方,那 么一个读周期就可以读出这32bit,而如果存放在奇

地址开始的地方,就需要2个读周期,并对两次读出的结果的高低字节进行拼

凑才能得到该32bit数据。

struct s1 {};

sizeof(s1) ; // 结果为1,空结构体也要被存储,编译器只能为其分配一个字节的空间占位。

struct s2 {char a; long b; static int c;}

sizeof(s2); // 结果为8。static存放在全局区,故不计入。可以看到默认对齐很浪费空间。

struct s3{char name[5];int num;short score;}

sizeof(s3); // 结果为16,内存分布如下所示:

|char|char|char|char|

|char|----|----|----|

|--------int--------|

|--short--|----|----|

如果改变顺序

struct s4{int num; char name[5];short score;}

sizeof(s4); // 则变为12

|--------int--------|

|char|char|char|char|

|char|----|--short--|

再改变顺序

struct s4{int num; short score; char name[5];}

sizeof(s4); // 则变为12

|--------int--------|

|--short--|char|char|

|char|char|char|----|

下一个例子:

struct s5 { char a; int b; double c; bool d;};

 分析:该结构体最大长度double型,长度是8,因此结构体长度分两部分:

第一部分是a、 b、 c的长度和,长度分别为1,4,8,则该部分长度和为1

3,取8的大于13的最小倍数为16;第二部分为d,长度为1,取大于1的8的

最小倍数为8,两部分和为24,故sizeof(test2)=24;

再看:

struct s6{char a;s5 bb;//见上题int cc;}

分析:该结构体有三个成员,其中第二个bb是类型为test2的结构体,长度为

24,且该结构体最大长度成员类型为double型,以后成员中没有double

型,所以按bb分界为两部分:第一部分有a 、bb两部分,a长度为1,bb长

度为24,取8的大于25的最小倍数32;第二部分有cc,长度为4,去8的大于

4的最小倍数为8;两部分之和为40,故sizeof(test3)=40;


六、关于前置与后置的讨论

    《google c++ 编程风格》5.10中提到:对于迭代器和其他模板对象使用前缀形式 (++i) 的自增, 自减运算符,理由是前置自增 (++i) 通常要比后置自增 (i++) 效率更高。

另:《more effective c++》条款8也有类似的描述。

关于前置++和后置++的区别,《C专家编程》(P276,人民邮电出版社)如下描述:

  1. int a = 0;  

  2. ++ a;   //前置++  

  3. a++;    //后置++  

++a表示取a的地址,增加它的内容,然后把值放在寄存器中;

a++表示取a的地址,把它的值装入寄存器,然后增加内存中的a的值;

另外还有人通过从运算符重载的角度来探讨他们的不同,如下:

假设有一个类Age,描述年龄。该类重载了前置++和后置++两个操作符,以实现对年龄的自增。

  1. class Age     

  2. {     

  3. public:     

  4.     

  5.     Age& operator++() //前置++     

  6.     {     

  7.         ++i;     

  8.         return *this;     

  9.     }     

  10.     

  11.     const Age operator++(int) //后置++     

  12.    {     

  13.         Age tmp = *this;     

  14.         ++(*this);  //利用前置++     

  15.         return tmp;     

  16.     }     

  17.     

  18.     Age& operator=(int i) //赋值操作     

  19.     {     

  20.         this->i = i;     

  21.         return *this;     

  22.     }     

  23.     

  24. private:     

  25.     int i;     

  26. };    

从上述代码,我们可以看出前置++和后置++,有4点不同:返回类型不同、形参不同、代码不同、效率不同。

返回值类型的区别

前置++的返回类型是Age&,后置++的返回类型const Age。这意味着,前置++返回的是左值,后置++返回的是右值。左值和右值,决定了前置++和后置++的用法。

  1. int main()     

  2. {     

  3.     Age a;     

  4.     

  5.     (a++)++;  //编译错误     

  6.     ++(a++);  //编译错误     

  7.     a++ = 1;   //编译错误     

  8.     (++a)++;  //OK     

  9.     ++(++a);  //OK     

  10.     ++a = 1;   //OK     

  11. }    

 

a++的类型是const Age,自然不能对它进行前置++、后置++、赋值等操作。

++a的类型是Age&,当然可以对它进行前置++、后置++、赋值等操作 

a++的返回类型为什么要是const对象呢?

有两个原因:

A、如果不是const对象,a(++)++这样的表达式就可以通过编译。但是,其效果却违反了我们的直觉 。a其实只增加了1,因为第二次自增作用在一个临时对象上。

B、另外,对于内置类型,(i++)++这样的表达式是不能通过编译的。自定义类型的操作符重载,应该与内置类型保持行为一致 。

a++的返回类型如果改成非const对象,肯定能通过编译,但是我们最好不要这样做。

 ++a的返回类型为什么是引用呢?

这样做的原因应该就是:与内置类型的行为保持一致。前置++返回的总是被自增的对象本身。因此,++(++a)的效果就是a被自增两次。

 

形参的区别

前置++没有形参,而后置++有一个int形参,但是该形参也没有被用到。很奇怪,难道有什么特殊的用意?

其实也没有特殊的用意,只是为了绕过语法的限制

 

前置++与后置++的操作符重载函数,函数原型必须不同。否则就违反了“重载函数必须拥有不同的函数原型”的语法规定。

虽然前置++与后置++的返回类型不同,但是返回类型不属于函数原型。为了绕过语法限制,只好给后置++增加了一个int形参。

 

原因就是这么简单,真的没其他特殊用意。其实,给前置++增加形参也可以;增加一个double形参而不是int形参,也可以。只是,当时就这么决定了。

 

代码实现的区别

前置++的实现比较简单,自增之后,将*this返回即可。需要注意的是,一定要返回*this。

后置++的实现稍微麻烦一些。因为要返回自增之前的对象,所以先将对象拷贝一份,再进行自增,最后返回那个拷贝。

 

在Age的代码中,后置++利用了前置++来实现自增。这样做是为了避免“自增的代码”重复。

在本例中,自增的代码很简单,就是一行++i,没有必要这样做。但是在其它自增逻辑复杂的例子中,这么做还是很有必要的。

 

效率的区别

如果不需要返回自增之前的值,那么前置++和后置++的计算效果都一样。但是,我们仍然应该优先使用前置++,尤其是对于用户自定义类型的自增操作。

前置++的效率更高,理由是:后置++会生成临时对象。

 

从Age的后置++的代码实现也可以看出这一点。 


  1. const Age operator++(int) //后置++     

  2. {     

  3.     Age tmp = *this;     

  4.     ++(*this);  //利用前置++     

  5.     return tmp;     

  6. }    

很明显,tmp是一个临时对象,会造成一次构造函数和一次析构函数的额外开销。虽然,编译器在某些情况下可以优化掉这些开销。但是,我们最好不要依赖编译器的行为。

 
所以,在非内置类型的时候,尽量使用前置++,因为效率高(后置自增,效率低) 


七、关于字符串类中重载+、=运算符的注意事项

class String

{

private:

    int Length;

    char * Sp;

public:

    String(String & s)

    {

        Length = s.Length;

        Sp = new char[Length+1];

        strcpy(Sp, s.Sp);

    }

String(const char *s)

{

    Length = strlen(s);

    Sp = new char[Length+1];

    strcpy_s(Sp, Length+1, s);

}

String()

{

    Length = 0;

    Sp = NULL;

}

~String()

{

    if(Sp)

        delete[] Sp;

}

void Show()

{

    cout << Sp << endl;

}

friend String operator+(String &s1, String &s2);

String operator =(String &s)

{

    Length = s.Length;

    Sp = new char[Length+1];

    strcpy(Sp, s.Sp);

    return *this;

}

};


String operator+(String &s1, String &s2)

{

     String temp;

    temp.Length = s1.Length+s2.Length;

    temp.Sp = new char[temp.Length+1];

    strcpy(temp.Sp, s1.Sp);

    strcat(temp.Sp, s2.Sp);

    return temp;

}

此处=运行符被定义成成员函数,返回对象本身,+运算符则设计成友元函数,返回一个临时对象,这个临时对象的返回是需要调用拷贝构造函数的,如果采用缺省拷贝构造函数则会变成浅拷贝,但由于对象中涉及到动态内存分配,则必然出现不同对象指向同一段内存,对象生命期结束时析构会崩溃,这样就要求实现自己的拷贝构造函数,在这个函数中处理动态内存。


八、关于类模板

被多个源文件引用的函数模板,应当连同函数体一同放在头文件中,而不能像普通函数那样只将声明放在头文件中。同样适用于类模板,即类模板的声明与其内函数的实现应放在同一个头文件中。

九、关于虚函数的一种奇怪的现象

9.1、基类中的虚函数,如果设置成protected或private,则赋给它派生类的对象地址,编译是不能通过的。但如果将基类中的虚函数设置为public,再将派生类的虚函数设置为protected或private,则可以用基类的指针调用这个虚函数,从另一个角度来看实现了类体外调用protected或private权限的函数了。

class Base

{

    public:

        virtual void Show(void){cout << "Base's Show" << endl;}

}

class Drived: public Base

{

    private:

        virtual void Show(void){cout << "Drived's Show" << endl;}

}

Base *pB;

Drived d;

pB= &d;

pB->Show();

将调用派生类的private权限的Show

9.2、为何析构函数可以设置为虚函数

先看一个例子:

class Base

{

    public:

        Base(){cout << "Base created" <<endl;}

        virtual ~Base(){cout << "~Base destroyed" << endl;}

        virtual void Show(void)

        {

            cout << "Base Show" << endl;

        }

};


class Drived: public Base

{

    private:

        char *p;

    public:

        Drived()

        {

            cout << "Drived created" << endl;

        }

        ~Drived()

        {

            

            if(p!=NULL)

            {

                delete[] p; 

                p = NULL;

            }

            

            cout << "~Drived destroyed" <<endl;

        }

        void Show(void)

        {

            p = new char[20];

            cout << "Drived Show" << endl;

        }

};

int main(int argc, char *argv[])

{

    Base *pb;

    Drived *pd = new Drived();

    pb = pd;

    pb->Show();

    delete pb;  

    return 0;

}

如果基类中的析构函数没有设置成虚函数,则delete pb将只调用基类中的析构函数,派生类中的析构函数不会被调用,因此派生类中动态申请的内容new char[20]就无法释放!

十、构造函数

10.1、复制构造函数的安全漏洞

可以通过复制构造函数改变其它对象的私有成员数据,注意虽然不是友元类,但是属于同类,这与非友元类还是有本质的区别,所以并不是C++语法的不严格。

class A

{

public:

A(int i){ a = i;}

A(A& o);

        private:

         int x; 

}

A::A(A& o)

{

 x = o.x; 

         o.x = 10;   // 这里就改变了引用对象的私有成员变量!

}

10.2、默认生成法则

无论是否有其它的构造函数,如复制构造函数没有实现,则系统会默认实现一个。如果实现则系统会调用实现的复制构造函数。

如果没有任何构造函数,系统只会默认实现无参数的构造函数,如果定义对象时有参数,则必须实现有参数的构造函数。


参考资料:

1、《C++语言程序设计(第4版)》 郑莉 清华大学出版社


  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值