C++学习笔记5__错误处理和调试 动态内存 副本构造器

本文介绍C++中的异常处理机制,包括错误检测、异常抛出与捕获的方法,并详细讲解了动态内存分配与释放的过程及注意事项。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

错误处理和调试

编程风格有一篇文章 高质量C++/C编程指南  百度一下N多

我们输入13,从表面上看,程序既没有崩溃也没有报告出错,但它已经不正常了!

我们来看下13阶乘的正确结果是:13!== 6,227,020,800

 

在知道问题后,我们其实也很容易的就可以猜到错误发生的原因:计算阶乘的值超出了计算机所能表达的最大整数(至少在我们这台机子上)。

鉴于这类问题的纠正,小甲鱼在这里教大家一个新技巧:运用 climits 头文件

 climits 头文件?

 这个头文件从 C 的 limits.h 头文件引用过来的。

主要列出了各种数据类型在给定操作系统上的取值范围,并且把每种数据类型的最大可取值和最小可取值都分别定义为一个常量供我们比较。

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

USHORT_MAX 代表无符号整数类型的最大可取值。

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

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

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

我也同意

C语言和C++都有一个专为调试而准备的工具函数,就是 assert()函数。

这个函数是在C语言的 assert.h 库文件里定义的,所以包含到C++程序里我们用以下语句:#include <cassert>

assert()函数需要有一个参数,它将测试这个输入参数的真 or 假状态。

如果为真,Do nothing!

如果为假,Do something!

#include <iostream>
#include <cassert>

int main()
{
 int i=13;

 assert( i == 43 );

 return 0;
}

 

结果

Assertion failed: i == 43, file G:\我的学习笔记\C\assert\assert.cpp, line 8
Press any key to continue

捕获异常

同样为了对付潜在的编程错误(尤其是运行时的错误),捕获异常是一种完全不同的办法。

简单地说,异常(exception)就是与预期不相符合的反常现象。

 基本使用思路:

1. 安排一些C++代码(try语句)去尝试某件事 —— 尤其是那些可能会失败的事(比如打开一个文件或申请一些内存)

2. 如果发生问题,就抛出一个异常(throm语句)

3. 再安排一些代码(catch语句)去捕获这个异常并进行相应的处理。

 

捕获异常的基本语法如下:

try

{

// Do something.

// Throw an exception on error.

}

catch

{

// Do whatever.

}

Pay attention!每条 try 语句至少要有一条配对的 catch 语句。

必须定义 catch 语句以便让它接收一个特定类型的参数。

C++还允许我们定义多条 catch 语句,让每条 catch 语句分别对应着一种可能的异常类型:

catch(int e){ … }

catch(bool e){ … }

catch(…){ … }

最后一条 catch 语句可以捕获任何类型的异常。

 

在程序里,我们可以用 throw 保留字来抛出一个异常:throw 1;

在某个 try 语句块里执行过 throw 语句,它后面的所有语句(截止到这个 try 语句块末尾)将永远不会被执行。

与使用一个条件语句或 return 语句相比,采用异常处理机制的好处是它可以把程序的正常功能和逻辑与出错处理部分清晰地划分开而不是让它们混杂在一起。

 如何让函数抛出异常

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

type functionName(arguments) throw(type);

 

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

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

 

TIPS

 

如何使用异常是一个很容易引起争论的话题。

有些程序员使用异常来处理几乎所有的错误,但C++的创始人Bjarne Stroustrup(BS君)觉得它们正在被滥用。

所以使用异常的基本原则是:应该只用它们来处理确实可能不正常的情况。

 

作为一条原则,在构造器和析构器里不应该使用异常。

一位非常有经验的程序猿在这些方法里成功地使用了异常是有可能的,但稍有不慎就会导致严重的问题。

 

如果 try 语句块无法找到一个与之匹配的 catch 语句块,它抛出的异常将中止程序的执行。

在C++标准库里有个一名为 exception 的文件,该文件声明了一个 exception 的基类。

 

可以用这个基类来创建个人的子类以管理异常。

有经验的程序猿常常这么做,而如此抛出和捕获的是 exception 类或其子类的对象。

如果你打算使用对象作为异常,请记住这样一个原则:以”值传递”方式抛出对象,以”引用传递”方式捕获对象。

 例

#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();   //清除缓冲区
  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;
 }
}

 

作为一条原则,在构造器和析构器里不应该使用异常。

一位非常有经验的程序猿在这些方法里成功地使用了异常是有可能的,但稍有不慎就会导致严重的问题。

 

如果 try 语句块无法找到一个与之匹配的 catch 语句块,它抛出的异常将中止程序的执行。

在C++标准库里有个一名为 exception 的文件,该文件声明了一个 exception 的基类。

 

可以用这个基类来创建个人的子类以管理异常。

有经验的程序猿常常这么做,而如此抛出和捕获的是 exception 类或其子类的对象。

如果你打算使用对象作为异常,请记住这样一个原则:以”值传递”方式抛出对象,以”引用传递”方式捕获对象。

动态内存

 动态内存由一些没有名字、只有地址的内存块构成,那些内存块是在程序运行期间动态分配的。

它们来自一个由标准 C++ 库替你管理的”大池子”(装B术语称之为”内存池”)

从内存池申请一些内存需要用 new 语句,它将根据你提供的数据类型分配一块大小适当的内存。

你不必担心内存块的尺寸问题,编译器能够记住每一种数据类型的单位长度并迅速计算出需要分配多少个字节。

如果有足够的可用内存能满足你的申请,new 语句将返回新分配地址块的起始地址。

 

如果没有足够的可用内存空间?那么 new 语句将抛出 std::bad_alloc 异常!

注意在用完内存块之后,应该用 delete 语句把它还给内存池。

另外作为一种附加的保险措施,在释放了内存块之后还应该把与之关联的指针设置为NULL。

图说编程:

int *i = new int;

int *i = new int;

 

delete i;

delete i;

 

i = NULL;

i = NULL;

NULL指针

 有一个特殊的地址值叫做 NULL 指针。当把一个指针变量设置为 NULL 时,它的含义是那个指针将不再指向任何东西:

int *x;

x = NULL; // x 这时候啥都不指向

 

我们无法通过一个被设置为 NULL 的指针去访问数据。

事实上,试图对一个 NULL 指针进行解引用将在运行时被坚持到并将导致程序中止执行。

所以在用 delete 释放内存后,指针会保留一个毫无意义的地址,我们要将指针变量赋值为 NULL。

 

pay attention

 请注意,静态内存这个术语与 C++ 保留字 static 没有任何关系。

静态内存意思是指内存块的长度在程序编译时被设定为一个固定的值,而这个值在程序运行时是无法改变的。

new 语句返回的内存块很可能充满”垃圾”数据,所以我们通常先往里边写一些东西覆盖,再访问它们,或者在类直接写一个构造器来初始化。

在使用动态内存时,最重要的原则是每一条 new 语句都必须有一条与之配对的 delete 语句,没有配对的 delete 语句或者有两个配对的 delete 语句都属于编程漏洞。(尤其前者,将导致内存泄漏)

为对象分配内存

 为对象分配内存和为各种基本数据类型(int, char, float…)分配内存在做法上完全一样。

用 new 向内存池申请内存

用 delete 来释放内存

 

#include <iostream>
#include <string>

class Company
{
public:
 Company(std::string theName);
 virtual void printInfo();

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;
}

对象的时候,千万不要忘记把方法声明为虚方法,如仍有疑问请回顾《C++快速入门》第二十三讲 —— 虚方法。

在重新使用某个指针之前千万不要忘记调用 delete 语句,如果不这样做,那个指针将得到一个新内存块的地址,而程序将永远也无法释放原先那个内存块,因为它的地址已经被覆盖掉了。

 

请记住,delete 语句只释放给定指针变量正指向的内存块,不影响这个指针。

在执行 delete 语句之后,那个内存块被释放了,但指针变量还依然健在哦。

 动态数组

把数组下标操作符和该指针变量的名字搭配使用就可以像对待一个数组那样使用 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>

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 m=0; m<count; m++)
 {
  std::cout << "x[" << m << "]的值是: " << x[m] << "\n";
 }

 delete []x;
 x = NULL;
 
 return 0;
}

从函数或方法返回内存
函数指针和指针函数
今天的知识点很容易让大家联想起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 coyp)。

这种行为在绝大多数场合都没有问题,但如果某些成员变量是指针的话,问题就来了:对象成员进行逐位复制的结果是你将拥有两个一摸一样的

实例,而这两个副本里的同名指针会指向相同的地址。。。

 

于是乎,当删除其中一个对象时,它包含的指针也将被删除,但万一此时另一个副本(对象)还在引用这个指针,就会出问题!

那聪明的鱼油这时候可能会说”小甲鱼你hold住,如果我在第二个副本同时也删除指针,不就行了吗?”

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

 

那么怎样才能解决这个问题呢?
在遇到问题的时候,人总是会想要是当初怎怎怎,现在就能咋咋咋酱紫。。。。。这听起来像是在后悔说的话,但对于编程来说,绝对是有后悔药的!

要是程序员在当初进行对象”复制”时能够精确地表明应该复制些什么和如何赋值,那就理想了。

C++语言的发明者早就预料到这个问题,并提出了一个解决方案,虽然方案有点曲折复杂,但是你不用担心,小甲鱼会适当放慢脚步带着你前进的!此时,国歌响起。。。。。。

 

分析下面几行代码:

MyClass obj1;

MyClass obj2;

obj2 = obj1;

 

前两行代码很简明,它们创建出了两个MyClass类的实例obj1和obj2。第三行代码把obj1的值赋值给了obj2,这里就可能会埋下祸根!

那么,怎样才能截获这个赋值操作并告诉它应该如何处理那些指针呢?

 

重载赋值操作符
 

答案是对操作符进行重载!没错,提供重载机制,事实上就是提供给我们后悔药(+红)!

我们知道几乎所有的C++操作符都可以重载,而赋值操作符”=”恰好是”几乎所有”中的一个。

 

我们将重载”=”操作符,在其中对指针进行处理:

MyClass &operator = (const MyClass &rhs);

 

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

因为这里使用的参数是一个引用,所以编译器在传递输入参数时就不会再为它创建另外一个副本(否则可能导致无限递归)

又因为这里只需要读取这个输入参数,而不用改变它的值,所以我们用const把那个引用声明为一个常量确保万无一失。

 

返回一个引用,该引用指向一个MyClass类的对象。如果看过我们待会实现的源码,可能会发觉这个没有必要。但是,这样确实是一个好习惯!

另外的好处是方便我们把一组赋值语句串联起来,如:a = b = c;

这少一个例子

 改在下面

下面是副本构造器

只对赋值操作符进行重载还不能完美地解决问题,正如刚才所说的,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 )
 {
  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(3));
 MyClass obj4 = obj3;
 obj3.print();
 obj4.print();

 std::cout << "----------------------------------------------\n";

 MyClass obj5(new int(5));
 obj5 =obj5;
 obj5.print();

 return 0;
}

这个例子有错误,还没改好。。。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值