C++快速入门学习笔记(三)

二十一、虚继承

前一节的 student 示例程序看起来似乎已经解决了问题,但它存在着一些隐患。

首先,在 TeachingStudent 类的 introduce() 方法里,我们不得不明确地告诉编译器应该使用哪一个属性。

这对于 classes 属性来说是应该的,因为教一门课和上一门课有着本质的区别,而作为常识,助教生教的课程和他学的课程不可能一样!

但是我们再深入考虑下,既然在 TeachingStudent 对象里可以继承两个不同的 classes 属性,那它是不是应该有两个不同的 name 属性呢?
答案:是!事实上,TeachingStudent 还真可以有两个不同的名字,这肯定不是我们在设计这个类继承模型时所预期的

TeachingStudent::TeachingStudent(std::string theName,std::string theName2,std::string classTeaching, std::string classAttending)
: Teacher(theName, classTeaching), Student(theName2, classAttending)
{
}
void TeachingStudent::introduce()
{
    std::cout << "大家好,我是" << Teacher::name << "。我教" << Teacher::classes << ", ";
    std::cout << "大家好,我是" << Student::name << "。同时我在" << Student::classes << "学习。\n\n";
}

C++ 发明者也想到了这部分的冲突,因此为此提供了一个功能可以解决这个问题:虚继承(virtual inheritance)
通过虚继承某个基类,就是在告诉编译器:从当前这个类再派生出来的子类只能拥有那个基类的一个实例。
虚继承的语法:

class Teacher : virtual public Person
{}
class Student : virtual public Person
{}
TeachingStudent::TeachingStudent(std::string theName,std::string theName2,
                                 std::string classTeaching,
                                 std::string classAttending)
                                 : Person(theName2), Teacher(theName, classTeaching), Student(theName2, classAttending)
{
}
void TeachingStudent::introduce()
{
    std::cout << "大家好,我是"<< name << "。同时我在" << Teacher::name << "。我教" << Teacher::classes << ", ";
    std::cout << "大家好,我是" << Student::name << "。同时我在" << Student::classes << "学习。\n\n";
}
int main()
{
    TeachingStudent teachingStudent("丁丁","丁丁2", "C++入门班", "C++进阶班");//最后只会有一个名字,就是继承自person的
    teachingStudent.introduce();
    return 0;
}

在这里插入图片描述

这样做我们的问题就解决了:让 Student 和 Teacher 类都虚继承自 Person 类,编译器将确保从 Student 和 Teacher 类再派生出来的子类只能拥有一份 Person 类的属性!

二十二、错误处理和调试

程序出错可以分为两大类:编译时错误(complie-time error)和运行时错误(run-time error)

编译时错误(complie-time error)

相比之下,编译时错误显然是比较轻的。因为编译将会告诉你它发现了什么错误和它是在哪行代码发现了这个错误的。
我们需要做的只是认真观察和分析编译器给出的出错信息,然后按语法要求改正即可。
下边总结一些编程好经验给大家参考:

建议一:培养并保持一种编程风格!
第一个建议是在编程的时候保持一种风格,一旦决定了要如何命名变量和函数、要按何种格式编写代码、如何缩进代码块等,就应该一直保持下去。
编程风格规范参考.pdf

建议二:认真对待编译器给出的错误/警告信息
有时候,编译器给出的警告信息完全没道理,但大多数时候还是很有用的,虽然警告不影响程序编译,但千万不要忽视它们。

建议三:三思而后行
开始写代码前先画流程图
编译错误不要立刻修改源代码,应该先完整地审阅一遍源代码,再开始纠正错误。因为冒失地修改源代码往往会造成错误越改越多、心情越改越乱的纠结局面。

建议四:注意检查最基本的语法

建议五:把可能有问题的代码行改为注释
不要轻易整行整行地删除代码,把可能有问题的代码行先改成注释,看错误是否还在。排除法。

建议六:换一个环境或开发工具试试
一般来说,编译器不会有问题,但如果你始终无法确定问题出在哪里,不妨换一下编译器或者操作系统,常常有时候弱智的杀软会阻止编译器导致编译器犯傻。

建议七:检查自己是否已经把所有必要的头文件全部 include 进来。
例如只有 #include 才能使用 cout,与此相类似的情况成百上千,并容易忽略,注意调用不熟悉的函数前查看相关文档,确定该函数需要哪些头文件支持。

建议八:留意变量的作用域和命名空间
程序代码对变量的访问权限可能导致各种各样的问题,这个知识今后我们会深入探讨。

建议九:休息一下!
在情绪变得越来越焦躁的时候,你发现和解决问题的能力会直线下降。这时候应该让自己放松一下,离开计算机,等头脑清醒了再回来解决问题。做开发的时候也是同样哦 _

建议十:使用调试工具
绝大多数 IDE 都有一个内建的调试器,一定要学习使用它并经常使用它。

最后,避免错误的另一个好方法就是把调试好的代码另外保存起来并不再改动它。

然后把代码划分成各个模块,用它们(在你能保证它们都没有问题的情况下)来搭建新的应用程序,会让你减少很多开发和调试的时间。

运行时错误(run-time error)

运行时错误往往远比编译时错误更难以查找和纠正,运行时错误一般都不会有正式的出错信息。
它们的发生几率因不同程序思路不同而不同,很少有规律可循。
更多的情况是时有时无,有的程序在这台计算机上很正常,在另一台计算机上就总是出问题。或者,某几个用户经常遇到这样或那样的问题,其他用户却都正常。
运行时错误的外在表现可以说是千变万化!

经验一:还是培养并保持一种良好的编程风格!
杂乱无章是程序员最大的敌人,找到你最喜欢的编程风格然后一直保持下去吧!

经验二:多用注释,用好注释。
如果忘记了某段代码的用途和理由,再想回来调试修改这段代码可就费劲了。这里要注意的是,必须让注释和代码保持同步,一旦修改了代码,就应该对注释进行相应的修改。
注意不要做无谓的注释。

经验三:注意操作符的优先级
操作符的优先级决定着有关操作的发生顺序,如果想让一系列操作按照希望的顺序发生,最保险的方法是用括号来确保这种顺序。

此处应该有例子:example.cpp

另外,不要对操作顺序做任何假设 —— 在某些场合,++,* 和 -> 之类的高优先级操作符的行为也不见得是你想象那样哦。

#include <iostream>
int main()
{
    int a = 1, b;
    if( b = a-- )     // 此处将a的值-1赋值给b
    {
        std::cout << "Yeah!\n";//最后执行这句,上面是赋值所以b=1
    }
    else
    {
        std::cout << "Out!\n";
    }
    return 0;
}

经验四:千万不要忘记对用户输入和文件输入进行合法性检查
如果让某个程序员去调试他本人编写的代码,他往往不能把所有的漏洞全都找出来 —— 因为他会下意识地避免各种不正常的做法。
只有用户才会做出一些出乎意料的事情,对于来自用户的输入,一定要采用正确的方法来读取它们和检查它们的合法性,确保你将要处理的数据符合你对它们的要求。

经验五:不要做任何假设
不要想当然地认为一个应该发生的操作比如打开一个文件、创建一个新的内存块,等等。不要想当然地认为用户肯定会按照你的意愿去使用你的程序。

经验六:把程序划分成一些比较小的单元模块来测试
程序越长,就越难以测试,只要条件允许,就应该把一个比较大的程序划分成一系列比较小的单元模块来分别加以测试。

让函数返回错误代码

让程序能够自行处理潜在错误的办法之一是创建一些测试函数:专门测试某种条件并根据测试结果返回一个代码来表示当前函数的执行状态。

int myFunction() {
    if( condition ) {
        return 0;
    } 
    else {
        return 1;
    }
}

这种方式我们已经了然于心,但我们仍有进一步继续讨论的必要。
以前写过的求阶乘的例子:factorial.cpp充满隐患,为什么?
我们输入13,从表面上看,程序既没有崩溃也没有报告出错,但它已经不正常了!

我们来看下13阶乘的正确结果是:6227020800

在这里插入图片描述

在知道问题后,我们其实也很容易的就可以猜到错误发生的原因:计算阶乘的值超出了计算机所能表达的最大整数int(至少在我们这台机子上)。
鉴于这类问题的纠正,有一个新技巧:运用 climits 头文件
这个头文件从 C 的 limits.h 头文件引用过来的。主要列出了各种数据类型在给定操作系统上的取值范围,并且把每种数据类型的最大可取值和最小可取值都分别定义为一个常量供我们比较。

比如,SHORT_MAX 代表短整数类型在给定系统上的最大可取值,SHORT_MIN 代表短整数类型在给定操作系统上的最小可取值。USHORT_MAX 代表无符号整数类型的最大可取值。

在这个程序里,为了判断阶乘计算的结果没有超出一个无符号长整数的最大取值,我们可以使用 ULONG_MAX 来提前获得这个值进行对比。

一起动手来修改这个代码:factorial2.cpp

#include <iostream>
#include <climits>
class Factorial
{
public:
    Factorial(unsigned short num);
    unsigned long getFactorial();
    bool inRange();
private:
    unsigned short num;
};
Factorial::Factorial(unsigned short num)
{
    this->num = num;
}
unsigned long Factorial::getFactorial()
{
    unsigned long sum = 1;
    for( int i=1; i <= num; i++ )
    {
        sum *= i;
    }
    return sum;
}
bool Factorial::inRange()
{
    unsigned long max = ULONG_MAX;
    for( int i=num; i >= 1; --i )//如果用最大值/13!会小于1,所以判断超过最大值
    {
        max /= i;
    }
    if( max < 1 )
        return false;
    else
        return true;
}
int main()
{
    unsigned short num = 0;
    std::cout << "请输入一个整数: ";
    std::cin >> num;
    Factorial fac(num);
    if( fac.inRange() )
    {
        std::cout << num << "的阶乘值是" << fac.getFactorial() << "\n\n";
    }
    else
    {
        std::cout << "您所输入的值太大!\n\n";
    }
    return 0;
}

有些程序员喜欢使用异常(咱们将在稍后学习)而不是使用 return 语句。

反对使用这个例子程序里所演示的技巧的主要理由是:把各种出错代码进行处理的语句混杂在程序的主干部分既不利于模块化编程,又容易干扰正常的思考!

二十三、assert函数和捕获异常

C语言和C++都有一个专为调试而准备的工具函数,就是 assert()函数。
这个函数是在C语言的 assert.h 库文件里定义的,所以包含到C++程序里我们用以下语句:
#include
assert()函数需要有一个参数,它将测试这个输入参数的真 or 假状态。
如果为真,Do nothing!
如果为假,Do something!

#include <cassert>
int main()
{
    int i = 20;
    assert( i == 65 );
    return 0;
}

在这里插入图片描述

演示中,我们看到 assert()函数可以帮助我们调试程序。
我们可以利用它在某个程序里的关键假设不成立时立刻停止该程序的执行并报错,从而避免发生更严重的问题。
另外,除了结合 assert()函数,在程序的开发、测试阶段,我们还可以使用大量的 cout 语句来报告在程序里正在发生的事情。

同样为了对付潜在的编程错误(尤其是运行时的错误),捕获异常是一种完全不同的办法。
简单地说,异常(exception)就是与预期不相符合的反常现象。
基本使用思路:

  1. 安排一些C++代码(try语句)去尝试某件事 —— 尤其是那些可能会失败的事(比如打开一个文件或申请一些内存)
  2. 如果发生问题,就抛出一个异常(throw语句)
  3. 再安排一些代码(catch语句)去捕获这个异常并进行相应的处理。

每条 try 语句至少要有一条配对的 catch 语句。必须定义 catch 语句以便让它接收一个特定类型的参数。
C++还允许我们定义多条 catch 语句,让每条 catch 语句分别对应着一种可能的异常类型:
catch(int e){ … }
catch(bool e){ … }
catch(…){ … }
最后一条 catch 语句可以捕获任何类型的异常。

#include <iostream>
#include <climits>
unsigned long returnFactorial(unsigned short num) throw (const char *);
int main()
{
    unsigned short num = 0;
    std::cout << "请输入一个整数: ";
    while( !(std::cin>>num) || (num<1) )
    {
        std::cin.clear();             // 清除状态
        std::cin.ignore(100, '\n');   // 清除缓冲区
        std::cout << "请输入一个整数:";
    }
    std::cin.ignore(100, '\n');
    try
    {
        unsigned long factorial = returnFactorial(num);
        std::cout << num << "的阶乘值是: " << factorial;
    }
    catch(const char *e)
    {
        std::cout << e;
    }
    return 0;
}
unsigned long returnFactorial(unsigned short num) throw (const char *)
{
    unsigned long sum = 1;
    unsigned long max = ULONG_MAX;
    for( int i=1; i <= num; i++ )
    {
        sum *= i;
        max /= i;
    }
    if( max < 1 )
    {
        throw "悲催。。。该基数太大,无法在该计算机计算求出阶乘值。\n";
    }
    else
    {
        return sum;
    }
}

在这里插入图片描述

  1. 在程序里,我们可以用 throw 保留字来抛出一个异常:throw 1;
    在某个 try 语句块里执行过 throw 语句,它后面的所有语句(截止到这个 try 语句块末尾)将永远不会被执行。
    与使用一个条件语句或 return 语句相比,采用异常处理机制的好处是它可以把程序的正常功能和逻辑与出错处理部分清晰地划分开而不是让它们混杂在一起。

  2. 你可以在定义一个函数时明确地表明你想让它抛出一个异常,为了表明你想让它抛出哪种类型的异常,可以使用如下所示语法:
    type functionName(arguments) throw(type);

  3. 如果没有使用这种语法来定义函数,就意味着函数可以抛出任意类型的异常。

  4. 注:有些编译器不支持这种语法,则可省略 throw(type) 部分。

  5. 如果 try 语句块无法找到一个与之匹配的 catch 语句块,它抛出的异常将中止程序的执行。
    在C++标准库里有个一名为 exception 的文件,该文件声明了一个 exception 的基类。可以用这个基类来创建个人的子类以管理异常。
    有经验的程序猿常常这么做,而如此抛出和捕获的是 exception 类或其子类的对象。
    如果你打算使用对象作为异常,请记住这样一个原则:以”值传递”方式抛出对象,以”引用传递”方式捕获对象。

二十四、动态内存管理

到目前为止所讲解的每一个示例程序在完成它的任务时所使用的内存空间都是固定不变的。
这个固定不变的内存空间其不实是在编写程序时候就可以知道和确定(一般以变量的形式)。这些程序都不能再程序运行期间动态增加或减少内存空间。
物理老师告诉我们:没有完全静止的东西!现实世界是动态的!所以,C++也必须支持动态管理内存。

你见过要求用户输入的文本必须不多不少包含多少个字符的程序吗?不可能吧?!
在很多时候,需要存储的数据量到底有多大在事先往往是一个未知数,要想处理好这类情况,就需要在C++程序里使用动态内存。
动态内存支持创建和使用种种能够根据具体需要扩大和缩小的数据结构,它们只受限于计算机的硬件内存总量和系统特殊约束。
接下来,我们将学到如何以这种灵活的方式与内存打交道。

静态内存

静态内存就是我们此前一直在使用的东西:变量(包括指针变量)、固定长度的数组、某给定类的对象。我们可以在程序代码里通过它们的名字或者地址来访问和使用它们。

使用静态内存的最大弊端是,你不得不在编写程序时为有关变量分配一块尽可能大的内存(以防不够存放数据)。一旦程序开始运行,不管实际情况如何,那个变量都将占用那么多的内存,没有任何办法能改变静态内存的大小。

动态内存

动态内存由一些没有名字、只有地址的内存块构成,那些内存块是在程序运行期间动态分配的。
它们来自一个由标准 C++ 库替你管理的”大池子”(术语称之为”内存池”)
从内存池申请一些内存需要用 new 语句,它将根据你提供的数据类型分配一块大小适当的内存。你不必担心内存块的尺寸问题,编译器能够记住每一种数据类型的单位长度并迅速计算出需要分配多少个字节。
如果有足够的可用内存能满足你的申请,new 语句将返回新分配地址块的起始地址。

如果没有足够的可用内存空间?
那么 new 语句将抛出 std::bad_alloc 异常!
注意在用完内存块之后,应该用 delete 语句把它还给内存池。另外作为一种附加的保险措施,在释放了内存块之后还应该把与之关联的指针设置为NULL。

图说编程:int *i = new int; 分配了一个红色区域的内存空间,把首地址存到i中。
在这里插入图片描述

delete i; 把之前分配的内存区域清空,释放内存。

在这里插入图片描述

i = NULL; 把与之关联的指针设置为NULL

在这里插入图片描述

  1. 有一个特殊的地址值叫做 NULL 指针。当把一个指针变量设置为 NULL 时,它的含义是那个指针将不再指向任何东西:
    int *x;
    x = NULL; // x 这时候啥都不指向
    我们无法通过一个被设置为 NULL 的指针去访问数据。事实上,试图对一个 NULL 指针进行解引用将在运行时被检测到并将导致程序中止执行。
    所以在用 delete 释放内存后,指针会保留一个毫无意义的地址,我们要将指针变量赋值为 NULL。
  2. 请注意,静态内存这个术语与 C++ 保留字 static 没有任何关系。静态内存意思是指内存块的长度在程序编译时被设定为一个固定的值,而这个值在程序运行时是无法改变的。
    new 语句返回的内存块很可能充满”垃圾”数据,所以我们通常先往里边写一些东西覆盖,再访问它们,或者在类直接写一个构造器来初始化。
    在使用动态内存时,最重要的原则是每一条 new 语句都必须有一条与之配对的 delete 语句,没有配对的 delete 语句或者有两个配对的 delete 语句都属于编程漏洞。(尤其前者,将导致内存泄漏)
  3. 为对象分配内存和为各种基本数据类型(int, char, float…)分配内存在做法上完全一样。(之前虚函数提过)
    用 new 向内存池申请内存
    用 delete 来释放内存
#include <iostream>
#include <string>
class Company
{
public:
    Company(std::string theName);
    virtual void printInfo();//注意这里的virtual不要忘了
protected:
    std::string name;
};
class TechCompany : public Company
{
public:
    TechCompany(std::string theName, std::string product);
    virtual void printInfo();
private:
    std::string product;
};
Company::Company(std::string theName)
{
    name = theName;
}
void Company::printInfo()
{
    std::cout << "这个公司的名字叫:" << name << "。\n";
}
TechCompany::TechCompany(std::string theName, std::string product) : Company(theName)
{
    this->product = product;
}
void TechCompany::printInfo()
{
    std::cout << name << "公司大量生产了 " << product << "这款产品!\n";
}
int main()
{
    Company *company = new Company("APPLE");
    company -> printInfo();
    delete company;
    company = NULL;
    company = new TechCompany("APPLE", "IPHONE");
    company -> printInfo();
    delete company;
    company = NULL;
    return 0;
}
  1. 在重新使用某个指针之前千万不要忘记调用 delete 语句,如果不这样做,那个指针将得到一个新内存块的地址,而程序将永远也无法释放原先那个内存块,因为它的地址已经被覆盖掉了。
  2. 请记住,delete 语句只释放给定指针变量正指向的内存块,不影响这个指针。在执行 delete 语句之后,那个内存块被释放了,但指针变量还依然健在哦。

二十五、动态数组

  1. 虽然,前边我们讲过的用 new 给基本类型和对象在运行时分配内存,但它们的尺寸在编译时就已经确定下来 —— 因为我们为之申请内存的数据类型在程序里有明确的定义,有明确的单位长度!
    可是,总有些时候,必须要等到程序运行时才能确定需要申请多少内存,甚至还需要根据程序的运行情况追加申请更多的内存。
    从某种意义上讲,这样的内存管理才是真正的动态。

  2. 程序里有一个数组,它的长度在编写这个程序时是未知的,这意味着无法在定义这个数组时在方括号里给出一个准确的数字。
    int a[???];

  3. 如何解决这个问题呢?嗯,没错,是指针!
    数组名和下标操作符[] 的组合可以被替换成一个指向该数组的基地址的指针和对应的指针运算:
    int a[20];
    int *x = a;
    指针变量 x 指向数组 a 的地址,a[0] 和 *x 都代表数组的第一个元素。

  4. 于是,根据指针运算原则,a[1] 等价于 *(x+1)、a[2] 等价于 *(x+2),以此类推。

  5. 大家想想,我们把这个逻辑倒过来,会怎样?
    嗯,反过来也成立,并且帮了我们一个大忙:
    把一个数组声明传递给 new 语句将使它返回一个该数组基类型的指针。
    把数组下标操作符和该指针变量的名字搭配使用就可以像对待一个数组那样使用 new 语句为这个数组分配的内存块了。

新建一个动态数组

例如:
int *x = new int[10];
可以像对待一个数组那样使用指针变量 x :
x[1] = 45;
x[2] = 8;
当然,也可以用一个变量来保存该数组的元素个数:
int count = 10;
int *x = new int[count];

删除一个动态数组

删除一个动态数组要比删除其他动态数据类型稍微复杂一点。
因为用来保存数组地址的变量只是一个简单的指针,所以需要明确地告诉编译器它应该删除一个数组!
具体的做法是在 delete 保留字的后面加上一对方括号:delete [] x;

#include <iostream>
#include <string>
int main()
{
    unsigned int count = 0;
    std::cout << "请输入数组的元素个数: \n";
    std::cin >> count;
    int *x = new int[count];
    for (int i = 0; i < count; i++)
    {
        x[i] = i;
    }
    for (int i = 0; i < count; i++)
    {
        std::cout << "x[" << i
                  << "]的值是: " << x[i] << "\n";
    }
    return 0;
}

二十六、从函数或方法返回内存

动态内存的另一个常见用途是让函数申请并返回一个指向内存块的指针。掌握这个技巧很重要,尤其是在你打算使用由别人编写的库文件时。
如果不知道这个技巧,就只能让函数返回一个简单的标量值,如整型、浮点型或字符型。换句话说,它既不能返回一个以上的值,也不能返回数组之类比较复杂的数据结构。

这个技巧的基本思路并不复杂:在函数里调用 new 语句为某种对象或某种基本数据类型分配一块内存,再把那块内存的地址返回给程序的主代码,主代码将使用那块内存并在完成有关操作后立刻释放。

#include <iostream>
int *newInt(int value);
int main()
{
    int *x = newInt(20);
    std::cout << *x;
    delete x;
    x = NULL;
    return 0;
}
int *newInt(int value)
{
    int *myInt = new int;
    *myInt = value;
    return myInt;
}

为什么不应该让函数返回一个指向局部变量的指针

我们曾讨论过变量作用域的概念:函数或方法有它们自己的变量,这些变量只能在这个函数的内部使用,这些变量我们成为局部变量(local variable)。

我们又知道如何利用指针在某个函数内部改变另一个函数的局部变量的值(例如传址调用)。

#include <iostream>
void swap(int *x, int *y);
int main()
{
    int a, b;
    a = 3;
    b = 5;
    swap(&a, &b);
    std::cout << "a = " << a << "\n";
    std::cout << "b = " << b << "\n";
    return 0;
}
void swap(int *x, int *y)
{
#if 0 // if 0相当于注释 if 1 相当于执行
    int temp;
    temp = *x;
    *x = *y;
    *y = temp;
#endif
    *x ^= *y;
    *y ^= *x;
    *x ^= *y;
}

这是绕开变量作用域的一种手段,在某些场合是非常必要的。

任何一个函数都不应该把它自己的局部变量的指针作为它的返回值!因为局部变量在栈里,函数结束自动会释放。

如果你想让一个函数在不会留下任何隐患的情况下返回一个指针,那它只能是一个动态分配的内存块的基地址(因为动态分配是在堆中的地址,只有delete才能把它释放)

函数指针和指针函数

今天的知识点很容易让大家联想起C语言的指针函数,这里就借此之便给大家继续探讨下很容易混淆的两个概念:函数指针和指针函数

函数指针:
指向函数首地址的指针变量称为函数指针。

#include <stdio.h>
int fun(int x, int y);
int main()
{
        int i,a,b;
        int (*p)();    /* 声明函数指针 */
        scanf("%d",&a);
        p = fun;            /* 给函数指针p赋值,使它指向函数f */
        printf("请输入是个数字: \n");
        for(i=0; i < 10; i++)
        {
                scanf("%d", &b);
                a = (*p)(a, b);    /* 通过指针p调用函数f */
        }
        printf("The Max Number is:%d", a);
        return 0;
}
fun(int x, int y)
{
    int z;
    z = (x > y) ? x : y;
    return(z);
}

指针函数:
一个函数可以带回一个整型数据的值,字符类型值和实型类型的值,还可以带回指针类型的数据,使其指向某个地址单元。

二十七、副本构造器

我们可以把一个对象赋值给一个类型与之相同的变量。

编译器将生成必要的代码把”源”对象各属性的值分别赋值给”目标”对象的对应成员。这种赋值行为称之为逐位复制(bitwise copy)。

这种行为在绝大多数场合都没有问题,但如果某些成员变量是指针的话,问题就来了:对象成员进行逐位复制的结果是你将拥有两个一摸一样的实例,而这两个副本里的同名指针会指向相同的地址

于是乎,当删除其中一个对象时,它包含的指针也将被删除,但万一此时另一个副本(对象)还在引用这个指针,就会出问题!
这时候可能会说”如果我在第二个副本同时也删除指针,不就行了吗?”

我们姑且认为这样做逻辑上没有问题。但从实际上情况看是不可能的。因为,我们的CPU本身就是逐条指令执行的,那么就总会有个先慢顺序。当试图第二次释放同一块内存,就肯定会导致程序崩溃。

那么怎样才能解决这个问题呢?
要是程序员在当初进行对象”复制”时能够精确地表明应该复制些什么和如何赋值,那就理想了。

分析下面几行代码:
MyClass obj1;
MyClass obj2;
obj2 = obj1;
前两行代码很简明,它们创建出了两个MyClass类的实例obj1和obj2。第三行代码把obj1的值赋值给了obj2,这里就可能会埋下祸根!
那么,怎样才能截获这个赋值操作并告诉它应该如何处理那些指针呢?

答案是对操作符进行重载!我们知道几乎所有的C++操作符都可以重载,而赋值操作符”=”恰好是一个。我们将重载”=”操作符,在其中对指针进行处理:
MyClass &operator = (const MyClass &rhs);

上边的语句告诉我们这个方法所预期的输入参数应该是一个MyClass类型的、不可改变的引用。

因为这里使用的参数是一个引用,所以编译器在传递输入参数时就不会再为它创建另外一个副本(否则可能导致无限递归)
又因为这里只需要读取这个输入参数,而不用改变它的值,所以我们用const把那个引用声明为一个常量确保万无一失。
返回一个引用,该引用指向一个MyClass类的对象。如果看过我们待会实现的源码,可能会发觉这个没有必要。但是,这样确实是一个好习惯!另外的好处是方便我们把一组赋值语句串联起来,如:a = b = c;

#include <iostream>
#include <string>
class MyClass
{
public:
    MyClass(int *p);
    ~MyClass();
    MyClass &operator=(const MyClass &rhs);
    void print();
private:
    int *ptr;
};
MyClass::MyClass(int *p)
{
    ptr = p;
}
MyClass::~MyClass()
{
    delete ptr;
}
// a = b;
MyClass &MyClass::operator=(const MyClass &rhs)
{
    if( this != &rhs )
    {
        delete ptr; //先释放之前的内存
        ptr = new int; //开辟一块新内存
        *ptr = *rhs.ptr; //把值放到新内存中,就保持两个对象值一样,且在不同内存中,不会在释放内存时释放同一块空间而产生异常报错
    }
    else
    {
        std::cout << "赋值号两边为同个对象,不做处理!\n"; // obj1 = obj1;
    }
    return *this;
}
void MyClass::print()
{
    std::cout << *ptr << std::endl;
}
int main()
{
    MyClass obj1(new int(1));
    MyClass obj2(new int(2));
    obj1.print();
    obj2.print();
    obj2 = obj1;
    obj1.print();
    obj2.print();
    return 0;
}

只对赋值操作符进行重载还不能完美地解决问题,正如刚才所说的,C++的发明者把解决方案弄得有点儿复杂。
改写下测试代码:
MyClass obj1;
MyClass obj2 = obj1;

这与刚才那三行的区别很细微,刚才是先创建两个对象,然后再把obj1赋值给obj2。
现在是先创建一个实例obj1,然后再创建实例obj2的同时用obj1的值对它进行初始化。
虽然看起来好像一样,但编译器却生成完全不同的代码:编译器将在MyClass类里寻找一个副本构造器(copy constructor),如果找不到,它会自行创建一个。
即使我们对赋值操作符进行了重载(他也不会走那个方法),由编译器创建的副本构造器仍以”逐位复制”方式把obj1赋值给obj2。

换句话说,如果遇到上面这样的代码,即时已经在这个类里重载了赋值操作符,暗藏着隐患的”逐位复制”行为还是会发生。

想要躲开这个隐患,还需要亲自定义一个副本构造器,而不是让系统帮我们生成。
MyClass( const MyClass &rhs);

这个构造器需要一个固定不变(const)的MyClass类型的引用作为输入参数,就像赋值操作符那样。因为他是一个构造器,所以不需要返回类型,还记得吗?

#include <iostream>
#include <string>
class MyClass
{
public:
    MyClass(int *p);
    MyClass(const MyClass &rhs);
    ~MyClass();
    MyClass &operator=(const MyClass &rhs);
    void print();
private:
    int *ptr;
};
MyClass::MyClass(int *p)
{
    std::cout << "进入主构造器\n";
    ptr = p;
    std::cout << "离开主构造器\n";
}
MyClass::MyClass(const MyClass &rhs)
{
    std::cout << "进入副本构造器\n";
    *this = rhs;
    std::cout << "离开副本构造器\n";
}
MyClass::~MyClass()
{
    std::cout << "进入析构器\n";
    delete ptr;
    std::cout << "离开析构器\n";
}
MyClass &MyClass::operator=(const MyClass &rhs)
{
    std::cout << "进入赋值语句重载\n";
    if( this != &rhs )
    {
        if(*ptr!=0)
            delete ptr;
        ptr = new int;
        *ptr = *rhs.ptr;
    }
    else
    {
        std::cout << "赋值号两边为同个对象,不做处理!\n"; // obj1 = obj1;
    }
    std::cout << "离开赋值语句重载\n";
    return *this;
}
void MyClass::print()
{
    std::cout << *ptr << std::endl;
}
int main()
{
    MyClass obj1(new int(1));
    MyClass obj2(new int(2));
    obj2 = obj1;
    obj1.print();
    obj2.print();
    std::cout << "-------------------------------\n";
    MyClass obj3(new int(0));
    MyClass obj4 = obj3;
    obj3.print();
    obj4.print();
    std::cout << "-------------------------------\n";
    MyClass obj5(new int(5));
    obj5 = obj5;
    obj5.print();
    return 0;
}

二十八、高级强制类型转换

用动态内存中的公司和子类科技公司举例。

传统的强制类型转换实现:把所需要的指针类型放在一对圆括号之间,然后写出将被强制转换的地址值。

Company *company = new Company("APPLE", "Iphone");
//TechCompany *tecCompany =  company; //这样编译不通过,因为两个是不同对象
TechCompany *tecCompany = (TechCompany *)company;
delete company;
company = NULL;
tecCompany = NULL;

在这里插入图片描述

注意不能既删除company,又删除tecCompany。因为强制类型转换操作不会创建一个副本拷贝,它只是告诉编译器把有关变量解释为另一种类型组合形式,所以他们指向的是同一个地址。

动态对象强制类型转换

这个程序它仍有一个问题没有解决:万一被强制转换的类型和目标类型结构完全不同,咋整?
编译器仍然将按照我们的代码行事!这样子的程序是相当危险的,随时可能崩溃以及被崩溃。
因为在类继承关系之间跳来转去(也就是对有关对象进行强制类型转换)在面向对象的程序里非常重要,所以C++程序员准备了几个新的强制类型转换操作符(高级)!

在这里插入图片描述

注:仍可以在C++里继续使用C的强制转换操作符,但表中的操作符还能进行必要的类型检查,因而能够改善程序的可靠性。

动态强制类型转换的语法看起来更像是一个函数调用:

Company *company = new Company("APPLE", "Iphone");
TechCompany *tecCompany = dynamic_cast<TechCompany *>(company);
if( tecCompany != NULL )
	std::cout << "成功!\n";
else
    std::cout << "失败!\n";//结果是失败了

先在两个尖括号之间写出想要的指针类型,然后是将被转换的值写在括号中。

二十九、避免内存泄漏

分配了一个内存块但忘记了释放它,这是一种严重的错误。这样的内存块将等到程序执行结束时才会被释放掉。

如果程序会运行很长时间(例如在服务器上,注意不是所有的操作系统都想windows一样每天都要重启哈)并且在不停地申请新内存块,忘记释放那些已经不再有用的老内存块将迟早会把内存消耗殆尽,直接导致后边的new操作无法执行甚至是崩溃!

这样的编程漏洞我们称之为内存泄漏(memory leak),new语句所返回的地址是访问这个内存块的唯一线索,同时也是delete语句用来把这个内存块归还给内存池的唯一线索。

int *x;
x = new int[1000];
delete[]  x;
x = NULL;

这意味着如果这个地址值(保存在x里)丢失了,就会发生内存泄漏问题。地址值会因为很多原因而丢失,比如因为一个指针变量被无意中改写,例如:

int *x;
x = new int[3000];
x = new int[4000];
delete[] x;
x = NULL;

会导致内存泄漏的另一种情况是用来保存内存块地址的指针变量作用域问题,例如:

void foo()
{
	My Class *x;
	x = new MyClass();
}

当foo()函数结束时,指针变量x将超出它的作用域,这意味着它将不复存在,它的值当然就会丢失。

有两种方法可以用来堵住这样的漏洞:

第一个方法是在return语句之前的某个地方插入一条delete x语句:

void foo()
{
    MyClass *x;
    x = new MyClass();
    delete x;
    x = NULL;
    return;
}

第二个方法是让函数把内存块的地址返回给它的调用者:

MyClass *foo()
{
    MyClass *x;
    x = new MyClass();
    return x;
}

内存作用域

  • 变量都有一个作用域:规定了它们可以在程序的哪些部分使用。

  • 这个作用域通常就是对它们做出声明和定义的函数的函数体,如main函数或某个子函数。

  • 如果被定义在任何一个函数的外部,变量将拥有全局作用域,这意味着它们可以在整个程序中的所有函数里使用。

  • 不过,应该尽量避免使用全局变量,因为它们往往会让代码变得难以调试和容易出错!

  • 动态内存不存在作用域的问题,一旦被分配,内存块就可以在程序的任何地方使用。

  • 因为动态内存没有作用域,所以必须由程序员来跟踪它们的使用情况,并在不再需要用到它们的时候把它们及时归还给系统。

  • 这里需要特别注意的是,虽然动态分配的内存块没有作用域,但用来保存其地址的指针变量是受作用域影响的。

三十、命名空间和模块化编程

两个相互关联的简单概念:

第一个概念是模块化(modularization)
把程序划分成多个组成部分(即所谓的“模块”)
这是通过把程序代码分散到多个文件里,等编译程序时再把那些文件重新组合在一起实现的。
第二个概念是命名空间(namespace)
这个概念相比起C语言是C++里新增加的东西,编写的程序越多、编写的程序越复杂,就越需要使用命名空间。

头文件

  • 只用一个源代码文件来保存程序的全部代码是可行的,但那会给编辑修改工作带来诸多不便。

  • 我们可以借助于C++的预编译器和编译器的能力把一个复杂的应用程序划分成多个不同的文件,而仍保持它在内容和功能上的完整。

  • C++预处理器的#include指令提供了一种能够让编译器在编译主程序时把其他文件的内容包括进来的机制。例如用这个指令来包括像iostream头文件我们已经用过很多次了。

  • 头文件的基本用途是提供必要的函数声明和类声明。比如string头文件就定义了字符串应该如何创建和使用。

  • 头文件可以细分为系统头文件和自定义头文件。

  • 顾名思义,系统头文件定义的都是系统级功能,正式因为有了它们,C++代码才可以在某种特定的系统上运行。

  • 如果你想在你的程序使用这些功能,就必须把相应的头文件包括到你的程序里来。

  • 系统头文件的另一个重要作用是保证C++代码的可移植性,确保同样的C++代码在不同的操作系统上做同样的事情。

  • 例如为Mac定义的cout和为Windows定义的cout做的事情一样,但内部的具体实现不见得一样。

  • 在#include指令里,系统头文件的文件名要放在尖括号里给出,这是告诉编译器:应该到“标准的”地点寻找这个文件:#include <stdio.h>

创建头文件

  • 在#include指令里,自定义头文件的文件名要放在双引号里给出:#include “xxxx.h”

  • 头文件是一些以.h作为扩展名的标准文本文件。一般情况下,都应该把自定义的头文件和其余的程序文件放在同一个子目录里,或者在主程序目录下专门创建一个子文件夹来集中存放它们。

  • 你可以用头文件来保存程序的任何一段代码,如函数或类的声明,但一定不要用头文件来保存它们的实现!

  • 与标准的C++源代码文件相比,在头文件里应该使用更多的注释。

  • 绝大多数头文件是通用型的,不隶属于任何特定的程序,所以至少把它的用途和用法描述清楚。

  • 应该在注释里说明的内容包括:创建日期,文件用途,创建者姓名,最后一次修改日期,有什么限制和前提条件等等。另外头文件里的每一个类和函数也应该有说明。

    // Rational.h
    // Create by xxx
    // 这个头文件声明了有理数类(Rational class)
    // 类里边对四则运算进行重载,以实现分数运算
    #include <iostream>
    class Rational
    {
    public:
        Rational(int num, int denom);  // num = 分子, denom = 分母
        Rational operator+(Rational rhs); // rhs == right hand side
        Rational operator-(Rational rhs);
        Rational operator*(Rational rhs);
        Rational operator/(Rational rhs);
    private:
        void normalize(); // 负责对分数的简化处理
        int numerator;    // 分子
        int denominator;  // 分母
        friend std::ostream& operator<<(std::ostream& os, Rational f);
    };
    
  • 虽说头文件可以用来保存任意代码片段,但典型的做法是只用它们来保存函数声明、用户自定义类型数据(结构和类)、模板和全局性的常量。

  • 如果你有一个程序需要多次调用一个或一组函数,或是你有一个或一组函数需要在多个程序里调用,就应该把它们的声明拿出来放到一个头文件里。

  • 头文件应该只包含最必要的代码,比如只声明一个类或只包含一组彼此相关的函数。

使用头文件

  • 如果没有给出路径名,编译器将到当前子目录以及当前开发环境中的其他逻辑子目录里去寻找头文件。

  • 为了消除这种猜测,在导入自己的头文件时可以使用相对路径。如果头文件与主程序文件在同一个子目录里,则可以这么写:#include “./xxxx.h”

  • 如果头文件位于某个下级子目录里,那么以下级子目录的名字开头:#include “includes/xxxx.h”

  • 最后,如果头文件位于某个与当前子目录平行的”兄弟”子目录里,则需要这么写:#include “…/includes/xxxx.h”

  • 请务必注意,Windows通常使用反斜杠作为路径名里的分隔符。

创建实现文件

  • 回到Rational这个例子,来进一步实现模块化编程。rational.h头文件包含Rational类的声明,但不包含这个类的实现代码。
  • 这种分割在实践中非常普遍。因为把**接口(函数的原型)和实现(函数体的定义)**分开是对代码进行模块化的基本原则之一。
  • 头文件的重要性不仅体现在它们可以告诉编译器某个类、结构或函数将有着怎样的行为,还体现在它们可以把这些消息告诉给程序员。
  • 需看到函数的声明就可以了解到你需要知道的一切:函数的名字,它的返回值类型和它的输入参数的类型和数量。
  • 知道了这些东西,就可以使用函数了,用不着关心它是如何工作的。编译器就不同了,必须读取某个类或函数的实现代码。
  • 作为一个通用原则,应该把声明放在一个头文件里,把实现代码放在一个.cpp文件里。
// Rational.h
// 这个头文件声明了有理数类(Rational class)
// 类里边对四则运算进行重载,以实现分数运算
#ifndef RATIONAL_H
#define RATIONAL_H
#include <iostream>
class Rational
{
public:
    Rational(int num, int denom);  // num = 分子, denom = 分母
    Rational operator+(Rational rhs); // rhs == right hand side
    Rational operator-(Rational rhs);
    Rational operator*(Rational rhs);
    Rational operator/(Rational rhs);
private:
    void normalize(); // 负责对分数的简化处理
    int numerator;    // 分子
    int denominator;  // 分母
    friend std::ostream& operator<<(std::ostream& os, Rational f);
};
#endif
// --------------------- Rational.cpp --------------------------- 
#include "rational.h"
#include <iostream>
#include <stdlib.h>

Rational::Rational(int num, int denom)
{
    numerator = num;
    denominator = denom;
    normalize();
}

// normalize() 对分数进行简化操作包括:
// 1. 只允许分子为负数,如果分母为负数则把负数挪到分子部分,如 1/-2 == -1/2
// 2. 利用欧几里德算法(辗转求余原理)将分数进行简化:2/10 => 1/5
void Rational::normalize()
{
    // 确保分母为正
    if( denominator < 0 )
    {
        numerator = -numerator;
        denominator = -denominator;
    }

    // 欧几里德算法
    int a = abs(numerator);
    int b = abs(denominator);

    // 求出最大公约数
    while( b > 0 )
    {
        int t = a % b;
        a = b;
        b = t;
    }

    // 分子、分母分别除以最大公约数得到最简化分数
    numerator /= a;
    denominator /= a;
}

// a   c   a*d   c*b   a*d + c*b
// - + - = --- + --- = ---------
// b   d   b*d   b*d =    b*d
Rational Rational::operator+(Rational rhs)
{
    int a = numerator;
    int b = denominator;
    int c = rhs.numerator;
    int d = rhs.denominator;

    int e = a*b + c*d;
    int f = b*d;

    return Rational(e, f);
}

// a   c   a   -c
// - - - = - + --
// b   d   b   d
Rational Rational::operator-(Rational rhs)
{
    rhs.numerator = -rhs.numerator;

    return operator+(rhs);
}

// a   c   a*c
// - * - = ---
// b   d   b*d
Rational Rational::operator*(Rational rhs)
{
    int a = numerator;
    int b = denominator;
    int c = rhs.numerator;
    int d = rhs.denominator;

    int e = a*c;
    int f = b*d;

    return Rational(e, f);
}

// a   c   a   d
// - / - = - * -
// b   d   b   c
Rational Rational::operator/(Rational rhs)
{
    int t = rhs.numerator;
    rhs.numerator = rhs.denominator;
    rhs.denominator = t;

    return operator*(rhs);
}

std::ostream& operator<<(std::ostream& os, Rational f)
{
    os << f.numerator << "/" << f.denominator;
    return os;
}
// --------------------- Main.cpp --------------------------- 
#include "rational.h"
#include <iostream>

int main()
{
    Rational f1(2, 16);
    Rational f2(7, 8);

    // 测试有理数加法运算
    std::cout << f1 << " + " << f2 << " == " << (f1+f2) << "\n";

    // 测试有理数减法运算
    std::cout << f1 << " - " << f2 << " == " << (f1-f2) << "\n";

    // 测试有理数乘法运算
    std::cout << f1 << " * " << f2 << " == " << (f1*f2) << "\n";

    // 测试有理数除法运算
    std::cout << f1 << " / " << f2 << " == " << (f1/f2) << "\n";

    return 0;
}

C预处理器

rationcal.cpp和main.cpp文件都包含了rational.h头文件。这意味着rational.h类被声明了两次,这显然没有必要(如果它是同一个结构,声明两次还将导致编译器报错)
解决方案之一是把其中一个文件里的#include删掉即可。这固然很容易可以解决的问题,但却会给今后留下麻烦。在这里提出是因为有更好的解决方案!利用C++预处理器,我们可以让头文件只在这个类还没有被声明过的情况下才声明它。预处理器的条件指令:
在这里插入图片描述

之前建议大家注释很多段代码的话用预处理的方式,比起/* */要效果好:

#if 0
	// 这里有代码
#endif

#ifndef  RATIONAL_H
#define  RATIONAL_H
class Rational{};
#endif
  • 这段代码的含义是:如果RATIONAL_H还没有定义,那么会定义一次RATIONAL_H,然后对Rational类做出声明等操作。
  • 这样一来,即使包含着这段代码的文件在某个项目里被导入了100次,Rational类也只会被声明一次,因为在第一次之后RATIONAL_H就有定义!
  • 作为一种固定模式,这里使用的常量名通常与相应的文件名保持一致,把句点替换为下划线。于是,rational.h文件将对应RATIONAL_H

命名空间

创建的每一个类、函数和变量都只能在一定的区域内使用。而我们所知道最大的区域是全局作用域,最小的区域是一个代码块,例如:

{
	int a = 20;
}
// a在此处已经不存在
  • 随着程序变得越来越复杂,全局作用域里的东西会越来越多,尤其是在使用外部函数库时。
  • 这可能会演变成一个问题:因为没有两样东西可以有同样的名字。
  • 解决方案之一是给每个变量、函数和类等取一个独一无二的名字,但这可能很困难或很麻烦(因为随着一个程序代码量的逐步增加,一个变量名可能会变成像”CountOfItemsInTheArray”才能不重复)。
  • 所以出现了命名空间!命名空间其实就是由用户定义的范围,同一个命名空间里的东西只要在这个命名空间有独一无二的名字就行了。
  • 因此,如果某个程序有许多不同的头文件或已编译文件,它们又各自声明了许多的东西,命名空间可以为它们提供保护。

创建命名空间

创建命名空间的办法很简单,先写出关键字namespace,再写出这个命名空间的名字,然后把这个命名空间里的东西全部括在一对花括号里就行了,如下所示:

namespace myNamespace
{
	// 全部东西
} //注意在最末尾不需要加上分号哦。

//正如我们刚才讲过的那样,命名空间可以让你使用同一个标识符而不会导致冲突:
namespace author
{
	std::string person;
}
namespace programmer
{
	std::string person;
}

现在,我们来把Rational类的定义放到它自己的命名空间里去。这样一来,我们就再也用不着担心它会与也叫做Rational的其他东西发生冲突了。

// Rational.h
#ifndef RATIONAL_H
#define RATIONAL_H
#include <iostream>
namespace myMath
{
	//class Rational中的声明内容 省略
}
#endif
// --------------------- Rational.cpp --------------------------- 
#include "rational.h"
#include <iostream>
#include <stdlib.h>
namespace myMath
{
	//class Rational中的实现内容 省略
}
// --------------------- Main.cpp --------------------------- 
#include "rational.h"
#include <iostream>
int main()
{
    myMath::Rational f1(2, 16); //使用时必须加上命名空间,否则编译不通过(未定义标识符)
    myMath::Rational f2(7, 8);
    // 测试有理数加法运算
    std::cout << f1 << " + " << f2 << " == " << (f1+f2) << "\n";
    return 0;
}

使用命名空间

如果某个东西是在命名空间里定义的,程序将不能立刻使用它。
这正是命名空间的全部意义所在:把东西放在属于自己的空间里,不让它们与可能有着相同名字的其他东西发生冲突。想要访问在某个命名空间里定义的东西,有三种方法。

  1. std::cout << “。。。。。”;
  2. 使用using指令,using namespace std; 执行这条语句后,在std命名空间里定义的所有东西就都可以使用,我们便可以像下面直接使用:cout << “…”;不过,把命名空间里的东西带到全局作用域里,跟我们使用命名空间的本意相违背!所以,不建议在文件开头直接用using namespace XX这种风格。
  3. 用一个using指令只把你需要的特定命名从命名空间提取到全局作用域:
    using std::cout;
    cout << “I love fishc.com!\n”;

注意:using指令的出现位置决定着从命名空间里提取出来的东西能在哪个作用域内使用。
如果你把它放在所有函数声明的前面,他将拥有全局性,如果你把它放在某个函数里,那么它将只在这一个函数里可以使用。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值