C++调试总结

一、参考:

  本文主要参考《C++编程调试秘笈》一书。

  在编写C++代码时,我们不应该自己捕捉缺陷,而是由编译器和可执行代码为我们做这些事情,该书便提供了这样的一个思考。作者以“调试器友好”的方式编写了一些方便安全检查时所需的宏代码并针对C++代码中最为常见的各种错误制定了一些规则,并用代码实现,使之很容易在运行时捕捉,或者尽可能地在编译时就捕捉缺陷。


二、C++缺陷来源 

  在C语言中为了追求简单和速度,产生高效的编译代码,有时候并未考虑一些方便用户的特性,就会产生一些比较明显的问题,比如垃圾回收,越界检查,缓冲区溢出等等

  1. 程序员可以创建一定长度的数组,并可用一个超出数组边界的索引值访问元素

  2. 滥用最多的是指针运算,程序员可以把指针运算所产生的任何值作为内存地址进行访问,不管该内存是否有效还是能否被访问,如解引用NULL指针strlen(NULL)将会导致程序崩溃

  3. 程序员在运行时使用calloc()malloc()函数动态分配内存并使用free()函数负责释放内存。但是如果忘了销毁,产生了内存泄露(分配内存后并未被释放,最终消耗完系统空间),或者不小心销毁了多次,产生内存悬挂(释放对象后没有将指针置为NULL而之后又解引用了它,未定义的指针解引用是非常严重的)等灾难性的问题

  4. sprintf()和某些字符串函数在写入缓冲区时,它们可能会改写越过缓冲区尾部的内存,从而导致不可预料的程序行为;相比对应的安全版本会安静地在缓冲区结束时截断,但很可能不是我们所期望的结果,建议多使用C++的stringstringstream
    关于C的字符串函数和C++ 的string 、stringstream孰优孰劣还是有争论的,有空的话可以分析分析

  当然C++语言中也存在一些问题

    1. 友元和多重继承并不是个很好的思路
    2. 混用了newdelete,其中一个带方括号和一个不带方括号,
      一定要使用正确的形式:
A* p_object=new A();
A* p_array=new A[size];
delete p_object;
delete []p_array;

  读完这本书,感触还是蛮深的,比如说C++早期的时候主要侧重在面向对象的特性方面的设计,后来陆续引入模板、异常处理、名字空间,到现在的C++11引入类型推导、lambda函数、标准程序库的变更(无序散列表、正则表达式、线程支持等),体会就是:

  • 语言的设计也是会演化的,它源于不断发展中实际的需求,设计什么样的特性是有舍有得的。
  • 设计思想和特性决定了它能做什么事,不能做什么事,有怎么的好处也有相应的缺陷。
  • 任何语言都不是silver bullet ,你不能单纯说它好坏. 只有当认识清楚语言背后的设计思想、演化史,了解各自的特性和缺点,就不会出现遇到具体问题而直接掉入编程语言的坑了

  觉得需要深入了解的主题:

  • Unix哲学编程艺术(Unix的设计思想是很值得思考和借鉴的)
  • C++语言的设计和演化、Java语言的演化设计史(虚拟机、设计模式,对比Java和C++的不同点)
  • 计算机程序的构造和解释,里面解释函数式编程语言它是如何工作的(表示一直不理解)
  • Python、Go这两种语言它有着怎样不同的设计

 三、何时捕捉陷阱   

  在编译时诊断错误,有如下规则:

  1. 禁止隐式类型转换:关键字explicit声明一个接受一个参数的构造函数,并禁止使用转换操作符
  2. 用不同的类表示不同的数据类型
  3. 不要使用单纯功能的枚举创建整形常量,而是用它们创建新类型

  原因如下:

  A. 假设我们有两个类A和B,并有一个期望接受一个B类型的参数的函数:

void doSomething(const B& b)

  但是我们不小心向它提供了A类型的对象:

A a(input);
doSomething(a);

  某些情况,这样的代码可通过编译,原因是它有可能平静的进行隐式类型转换:A转换成B。它可能通过以下两种方式发生

   1.B类接受含A类型的参数构造函数,它可以隐式地把A转换为B

class B {
    public:
        B(const A& a);
}

   2.A类具有一个可以将其转换为B的操作符,以明确的方式提供了转换方法

class A{
    public//转换操作符operator type():type可以是基本数据类型,类,结构体
         operator B() const; 
}

  所以针对上述问题,对于所有接受一个参数的构造函数用关键字explicit声明,并且不建议用转换操作符,这是值得推荐的做法。

  一般而言,隐式转换的所有可能性都是不好的思路,还记得深入计算机系统第二章讲过FreeBSD开源系统曾出现的getpeername的安全漏洞么,这是由于无符号数和有符号数间的不匹配造成了隐式类型转换。不过我们还可以用另外一个方法进行转换

class A{
    public:
         B asB() const; 
}

A a(input);
doSomething(a.asB()); //  显式转换

  B. 定义两个枚举,分别表示一周中的某天及月份,这些常量都是整数。假设我们有一个期望接受一周中的某天作为参数的函数

enum {SUN1,MON=1,TUE,WED,THU,FRI,SAT};
enum {JAN=1,FEB,...,DEC};

void func(int day_of_week);

  因而下面调用将不会产生任何警告的情况下通过编译:func(JAN);

  所以捕捉此类缺陷的办法就是创建新类型的枚举,直接限定了新类型的枚举范围,这样就可以在编译时判断是否有错误。

typedef enum {SUN1,MON=1,TUE,WED,THU,FRI,SAT} DayofWeek;
typedef enum {JAN=1,FEB,...,DEC} Month;

 

四、在运行时遇见错误如何处理

  我们把精力集中在运行时的一类错误--缺陷。为了捕捉缺陷专门编写的一段代码称为安全检查,当其失败时,就表示发现了缺陷,那如何处理呢,这里作者提供这样的一个思路

  定义一个SCPP_ASSERT宏,永久性的安全检查,用来捕捉运行时错误,并提供与错误有关的具体信息

#scpp_assert.h
#define SCPP_ASSERT(condition,msg) \
    if(!(condition)) {             \
    std:ostringstream s;       \
    s << msg;                  \
SCPP_AssertErrorHandler(__FILE__,__LINE__,s.str().c_str()); \
}

#scpp_assert.cpp
void SCPP_AssertErrorHandler(const char *file_name,
                             unsigned line_no,
                             const char *msg){
//此处适合插入断点,合适情况下还可向一个日志文件写入相同的信息
#ifdef SCPP_THROW_EXCEPTION_ON_BUG
     throw scpp::ScppAssertFailedException(file_name,
                                           line_no,msg);
#else
    cerr << msg << "in file "<<file_name << 
                   " #" <<line_no <<endl<<flush;
    exit(1);
#endif
}

#scpp.h
#ifdef SCPP_THROW_EXCEPTION_ON_BUG
#include<exception>

namespace scpp {
    class ScppAssertFailedException :public std::exception {
        private:
            std::string what_;
        public:
            ScppAssertFailedException(const char *file_name,                                      unsigned line_no,   
                                      const char *msg);
            virtual void const char* getwhat() const throw()          { return what_.c_str();}
            virtual ~ScppAssertFailedException() throw() {}
    }

}

#scpp_assert.cpp
#ifdef SCPP_THROW_EXCEPTION_ON_BUG
namespace scpp {
    ScppAssertFailedException::ScppAssertFailedException(const char *file_name,
                                                         unsigned line_no,
                                                         const char *msg) {
        ostringstream s;
        s << "SCPP Assertion failed with message " << msg <<" in file " <<file_name << " # " << line_no;
        what_=s.str();
    }
}
#endif

  我们可以看到该宏接受一个条件和一条错误信息。条件为真不执行任何事情,为假时错误信息会输出到ostringstream中,并且错误处理函数将被调用。这里有两个问题:

  • 问:为什么要调用scpp_assert.cpp文件中一个单独AssertErrorHandler函数,而不是在scpp_assert.h文件的宏中执行相同的操作
    答:调试器更擅长对函数而不是宏进行逐步调试

  • 问:为什么AssertErrorHandler函数向我们提供了两种选择机会,要么终止程序,要么抛出一个异常
    答:在最常见的情况下我们发现第一个缺陷时默认采取的办法是终止程序,修补缺陷并再次开始,这时候将打印出错误信息并终止程序,即对应没有定义的SCPP_THROW_EXCEPTION_ON_BUG符号。
    那么定义了该符号的情况呢,在某些情况下,有部分安全检查必须保留在代码中,即使是在产品模式下。假设有一个持续依次处理大量请求的程序在处理某个请求时安全检查失败,终止程序并不是理想的选择,应该采取的办法是抛出一个异常,包含详细的错误信息并把错误信息记录在某日志文件中,可能还需要发送邮件或警报,宣布对当前请求的处理失败,同时继续处理发送其他的请求。因而在scpp_assert.h声明了一个异常类

  • 问:什么时候编写安全检查?
    答:如果我们的想法是等我们编码好后再回过头来添加安全检查,这个计划可能永远不糊实施。
    较好的建议是从一开始编写新函数新类新功能时等具体的代码前就应该为它所有的输入编写好安全检查和测试。
    可以看出编写安全检查并不困难,它不仅让你更明确你所要做的工作,更重要的是它会在以后的测试阶段得到足够的回报,这要比你以后回过头来调试代码要方便得多。

  注:要养成这样的习惯,单元测试也是类似的思路:编码的同时编写好安全检查和测试,更明确的办法是当我们开始编写具体的代码前为它的所有输入编写安全检查

  如下类似的代码用来测试:

#include <iostream>
#include "scpp_assert.h"

using namespace std;
int main(int argc,char *argv[]) {
    cout << "Hello,SCPP_ASSERT" << endl;

    try {
        double price=100.0 ; //合理价格
        SCPP_ASSERT(0< price && price <=1e6,"Stock price " <<price <<" is out of range "); //条件成立时不执行

        price=-1;
        SCPP_ASSERT(0< price && price <=1e6,"Stock price " <<price <<" is out of range "); //条件不成立时执行并捕获异常
    } catch (const exception& ex) {
        cerr << "Exception caught in " << _FILE_ << " # "<< _LINE_ << ". "<< endl;
        cerr << ex.what() << endl;
    }
    return 0;
}

//在SCPP_ASSERT宏中也可使用任何类的对象,只要它定义了<< 操作符,设计和测试如下:
/* Test :
*MyClass obj(inputs);
*SCPP_ASSERT(obj.IsValid(),"Object "<< obj <<" is invalid.");
*/
class MyClass {
public:
    bool IsValid() const ; //对象状态有效即返回true
    //Implement constructors 、destructors 
private:
    int data;
    friend std::ostream operator << (std::ostream& os ,const MyClass& obj);
}
inline std::ostream operator << (std::ostream& os ,const MyClass& obj) {
    //执行一些任务,按被人理解的格式显示对象
    os << obj.data;
    return os;
}
/*
* Output : 
* Hello,SCPP_ASSERT
* Exception caught in xxx.cpp #13 .
* SCPP assertion failed with message 'Stock price -1 is out of range ' in file xxx.cpp #13
*/

  问:什么时候使用它
  答:我们意识到代码中可能含有大量的安全检查,有些是永久性的,有些是临时性的。为了保持C++代码执行的高效性和有效性,在不同运行阶段执行不同的策略:

    • 在Debug模式,打开测试安全检查,对错误进行调试
    • 在Release模式,打开测试安全检查,快速调试(考虑到1的安全检查会较慢)
    • 在Release模式下关闭安全检查,发布产品

  代码实现如下:

#scpp_assert.h
#ifdef _DEBUG
#define SCPP_TEST_ASSERT_ON
#endif

#ifdef SCPP_TEST_ASSERT_ON
#define SCPP_TEST_ASSERT(condition,msg) SCPP_ASSERT(condition,msg)
#else
#define SCPP_TEST_ASSERT(condition,msg)

  可以看到SCPP_ASSERT是永久性的安全检查,SCPP_TEST_ASSERT可以在编译时打开。


 

posted @ 2021-08-03 17:09  LyAsano  阅读(557)  评论(0编辑  收藏  举报