C++程序开发技巧

引言

类(class)的使用分为两种——基于对象(object Based)和面向对象(object oriented)

基于对象是指,程序设计中单一的类,和其他类没有任何关系

单一的类又分为:不带指针的类(class without pointer members)和带指针的类(class with pointer members)

面向对象则是类(class)中涉及了类之间的关系:复合(composition)、委托(delegation)、继承(inheritance)

1.头文件的防御式声明

#ifndef xxx
#define xxx
...
#endif

在编写头文件时应该有这样的一种习惯

目的是避免多次重复包含同一个头文件,否则会引起变量及类的重复定义

2.使用初始化列表的好处

  • 只有构造函数这类函数具有“初始化列表”这一特性
  • 从结果上来看,构造函数时使用初始化列表和在类内赋值是一样的,但我们都知道一个变量必须先初始化然后才被赋值,而初始化列表顾名思义是只执行初始化这一步,在类内赋值时就要先初始化再被赋值,所以从执行效率上讲,使用初始化列表会更快,也更简洁

3.设计模式:singleton(单例类)

  • demo:
class A {
public:
    static A & getInstance();
    setup() {...}
private:
    A();
    A(const A & rhs);
    ...
}

A & A::getInstance()
{
    static A a;
    return a;
}

...
//外部接口
A::getInstance().setup();
  • 原理:将构造函数设置为私有属性,同时设置一个静态函数接口返回一个该类对象

  • 作用:保证每一个类仅有一个实例,并为它提供一个全局访问点

  • 单例模式(Singleton)的主要特点不是根据用户程序调用生成一个新的实例,而是控制某个类型的实例唯一性。它拥有一个私有构造函数,这确保用户无法通过new直接实例它。除此之外,该模式中包含一个静态私有成员变量instance与静态公有方法Instance()。Instance()方法负责检验并实例化自己,然后存储在静态成员变量中,以确保只有一个实例被创建。

    这种模式主要有以下特征或条件:

    1. 有一个私有的无参构造函数,这可以防止其他类实例化它,而且单例类也不应该被继承,如果单例类允许继承那么每个子类都可以创建实例,这就违背了Singleton模式“唯一实例”的初衷。
    2. 单例类被定义为sealed,就像前面提到的该类不应该被继承,所以为了保险起见可以把该类定义成不允许派生,但没有要求一定要这样定义。
    3. 一个静态的变量用来保存单实例的引用。
    4. 一个公有的静态方法用来获取单实例的引用,如果实例为null即创建一个。
  • 参考:

    设计模式详解:Singleton(单例类)_singleton类_p_帽子戏法的博客-CSDN博客

    单例模式(Singleton)的6种实现 - JK_Rush - 博客园 (cnblogs.com)

4.常成员函数的重要性

  • 如果一个成员函数不改变类的数据成员时,就把它声明为常函数,这是一个好的习惯

  • 当实例化一个常对象时,常对象要求不能改变数据成员,如果成员函数不加const,将无法调用此成员函数,编译器不会通过,即使此函数确实没有改变数据成员;同时,即使成员函数被声明为了常函数,实例化一个普通对象时依然可以调用。

  • 简单来说,不声明为常成员函数可能不会有问题,但声明为常成员函数能确保一定不出问题

5.如何解释成员函数接收同类对象参数...

  • 问:如何解释一个类的成员函数在接收同类对象的参数(比如拷贝构造函数)可以直接调用该对象的任何成员,明明既不是友元也不是嵌套?

  • 答:相同class的各个objects互为friends(友元)

6.设计一个类要考虑什么

  • 目的:高效、安全、简洁、严密
  • 1.数据成员私有
  • 2.参数传递和返回值优先考虑用引用(传递的是地址值,这样不管传递的数据内存占用多大,依然固定传入四个字节,即使当传递字符这样小于四个字节时用值传递确实比引用或指针传递更快一些,但不必考虑这些细枝末节)
  • 3.构造函数优先去使用初始化列表
  • 4.能声明为常成员就声明为常成员

7.返回值加不加引用

  • 取决于返回的值是否要改变、是否可以改变,前者由我们决定,后者由语法限制

两个案例

class A
{
    int value;
    ...
};

...
    
A& fun1(A* x, const A& y)
{
    x.value += y.value; //第一参数会改变,第二参数不会改变
    return *x
}
class B
{
    int value;
    ...
};

...

B fun2(const B& x, const B& y)
{
	//第一参数和第二参数都不会改变
	return B(x.value + y.value);
}

8.运算符重载成员函数的思考

e.x.

inline complex&
_doapl(comlex* ths, const complex& r)
{
	...
	return *ths;
}

inline complex&
complex::operator += (const comlex& r)
{
	return _doapl(this, r);
}

...

comlex c1(2,1), c2(3), c3;
//c2 += c1;
//c3 += c2 += c1;

当重载一个二元运算符为成员函数时,我们知道重载函数除了右操作数是我们传递的,函数还会默认用一个this指针,来接收左操作数

那么可能会有疑问,我们想要改变的是左操作数,而且由于传递的是指针,函数内也确实可以改变,那返回值又有什么用呢,声明为空不就行了。

当我们使用重载运算符时只是像被注释的第一行代码一样,那么返回值确实不重要,但是当我们使用的形式像被注释的第二行代码时,那么返回值就很重要,因为c2.+=(c1) 这个函数的返回值就是 c3 += () 函数的参数

9.temp object(临时对象)

  • 语法: typename ();
  • 生存期:仅声明那一行
  • 返回值是临时对象时不难return by reference

10.<<重载的一些注意点

  • 只能重载为非成员函数

  • 左操作数固定为系统定义的 ostream 类型,且为非常量引用

    • 非常量:向流写入内容其实就是改变了它的状态
    • 引用:无法复制一个 ostream 对象
    • 注:ostream 类与 istream 类一样,它的的拷贝构造函数和赋值函数都是保护类型的,所以 ostream 是不允许拷贝或者赋值的,所以它也不能直接作为返回类型和参数传递,很多时候需要使用引用来进行传递。
  • 最好加返回值且为引用,原因前面已经说明,且我们对于连续调用<<的频率要大得多

  • 连续调用时的调用顺序

    complex c1, c2;
    cout << c1 << c2;
    //先执行 <<(cout, c1) 函数
    //返回的 ostream类型的cout的引用 又作为<<(ostream &, c2)的第一参数 
    
  • 在语法上我们当然也可以重载为成员函数,只要左操作数为自定义类型即可,但这样并不符合我们通常的书写习惯

11.Big Three(三位一体原则)

  • 三大件:拷贝构造、拷贝赋值、析构函数

  • 解释:当一个类需要我们去主动设计析构函数时,那它很大概率也需要一个拷贝构造函数和赋值运算符重载成员函数

  • 应用:当一个类具有指针成员时(class with point member)或者说当我们设计了一个有动态内存管理的类时

  • 原因:

    • 析构函数角度:默认析构函数会仅删除指向对象的指针,而删除一个指针不会释放指针指向对象占用的内存,最终会导致内存泄露

    • 拷贝构造角度:默认的构造函数是浅拷贝,复制的只是指针也就是地址值,这样导致两个对象共享一个内存空间,这是十分危险的,当其中一个对象被删除后,析构函数将释放那片共享的内存空间,接下来对这片已经释放了内存的任何引用都将会导致不可遇见的后果。

    • 赋值运算角度:

      赋值相比于拷贝构造要考虑更多

      首先是自我赋值判断,如果不判断,当左右操作数指向的是同一个地址时,会造成将左操作数对象的元素删除并释放其占用的内存,同时由于左右操作数指向同一对象,导致右操作数同时被删除,但接下来还要将右操作对象复制,这会造成不可预知的结果。这也被称为证同测试。

      其次是进行三步必要操作:

      • 释放已有内存
      • 开辟新的内存
      • 内容赋值
  • 代码示例:

#ifndef __MYSTRING__
#define __MYSTRING__

class String
{
public:                                 
   String(const char* cstr=0);//构造函数
   String(const String& str);//拷贝构造函数           
   String& operator=(const String& str);//重载=运算符 
   ~String();//析构函数                               
   char* get_c_str() const { return m_data; }//成员函数,返回指向字符数组首地址的指针
private:
   char* m_data;//字符数组指针
};

#include <cstring>

//构造函数
inline
String::String(const char* cstr)
{
   //开辟内存、计算长度、内容拷贝
   if (cstr) {
      m_data = new char[strlen(cstr)+1];
      strcpy(m_data, cstr);
   }
   else {   
      m_data = new char[1];
      *m_data = '\0';
   }
}

//析构函数
inline
String::~String()
{
   delete[] m_data;//释放指针指向空间
}

//重载=
inline
String& String::operator=(const String& str)
{
   //检测自我赋值(self assignment)
   if (this == &str)
      return *this;

   delete[] m_data;
   m_data = new char[ strlen(str.m_data) + 1 ];
   strcpy(m_data, str.m_data);
   return *this;
}

//构造函数
inline
String::String(const String& str)
{
   m_data = new char[ strlen(str.m_data) + 1 ];
   strcpy(m_data, str.m_data);
}

#include <iostream>
using namespace std;

//重载<<
ostream& operator<<(ostream& os, const String& str)
{
   os << str.get_c_str();
   return os;
}

#endif

12.new和构造函数,delete和析构函数

  • 当我们使用new创建了一个指向类的对象的指针时

    这里的new干了三件事:

    • 调用 operator new 函数,这个函数内部又调用了malloc函数来分配内存
    • operator new 函数返回的是空指针,显式转换为类类型指针后赋值给我们创建的指针
    • 调用指针指向对象的构造函数

  • 当我们使用delete释放一个指向对象的指针时
    • 首先调用对象的析构函数,这里该类的析构函数又用delete释放了类内动态分配的数组指针
    • 然后再释放这个指向对象的指针
    • 结合本例来看,就是delete ps,先delete它指向的成员,再delete它自己

13.malloc()动态分配内存的结构

  • 红色部分是 cookie ,记录内存分配的总大小,就是图中的41,其最低位用于表示是否已分配(1表示已分配,0表示已回收),之所以最低位可以变,是因为分配的内存总空间一定是16的倍数,其16进制表示时最低位一定为0,也就是说这个位置是空出来的,刚好用来表示内存状态。每一个 new 的对象都会有上下两个 cookie,来预先申请一块内存池,然后供对象实例化。

  • 绿色部分是调用malloc()时向系统申请的内存,该函数返回时,也会返回这块区域开头的指针。

  • 绿色部分上下两块 gap 预先被填充为了0xfdfdfdfd,用来分隔客户可以使用的内存区和不可使用的内存区,同时,当这块内存被归还时,编辑器也可以通过下gap的值区判断当前内存块是否被越界使用了

  • 从gap向上连续的7个内存空间共同组成了debug header,从上向下标号为1-7

    • 1、2两块空间保存了两根指针,目的是使多个内存块连接成链表。
    • 3空间保存了申请本内存块的文件名
    • 4空间保存了申请本内存块的代码行数
    • 5空间记录了本内存块中实际可以被用户使用的内存空间的大小
    • 6空间记录了当前内存块的流水号,即是链表中的第几个,从1开始
    • 7空间记录了当前内存块被分配的形式
  • 填补区pad

参考:

https://zhuanlan.zhihu.com/p/492161361

https://www.cnblogs.com/zyb993963526/p/15682014.html#_label2

https://blog.csdn.net/qq_61500888/article/details/122170203

14.array new 搭配 array delete

动态分配数组时要注意的:

  • 其内存区域相比上面所提到的多了一个内存块用来记录数组长度(分配对象数量)
  • 当申请内存后,返回的指针指向数据开始处,而使用 delete[] 释放时,指针会指向它的上一块,也就是记录数组长度的那一块,从而可以根据对象的数量调用相应次数的析构函数。如果使用 delete 释放的话,它不会去获取对象的长度,而是只调用指针指向的那一个对象的析构函数。
  • 如果对象的类型是内置类型或者是无自定义的析构函数的类类型,是可以使用 delete 来释放 new[] 对象的。但是,如若不然,使用 delete 来释放对象,对象所分配的内存空间虽然会照样全部释放,但是只会调用第一个对象的析构函数,这就导致内存泄漏。所以,养成良好的习惯,new [] 必 delete []

15.this指针

类的每一个非静态成员函数(包括构造函数、拷贝构造等)都隐含着一个指针形参名为this,当对象调用成员函数时就会隐含传递该对象的地址给它,这也是为什么一个类的成员函数虽然只有一份但也会根据接收的消息不同产生不同的行为,而静态成员函数不隐含this指针,所以即使调用它的对象不同维护的依然是同一段代码

16.namespace

三个案例:

  • 使用using引入一个命名空间的全部
  • 使用using引入一个命名空间的个体
  • 不用using,使用时手动引入

posted @ 2023-06-04 19:44  氢氧正根  阅读(217)  评论(3编辑  收藏  举报