以下为个人学习笔记整理。参考书籍《C++ Primer Plus》

# 类和动态内存分配

# 动态内存和类

# 编写一个简单的 string 类

stringbad.h

#pragma once
#include <iostream>
#ifndef STRINGBAD_H_
#define STRINGBAD_H_
class StringBad {
private:
	char* str;
	int len;
	static int num_strings;
public:
	StringBad(const char*);
	StringBad();
	~StringBad();
// friend func
	friend std::ostream& operator<<(std::ostream& os, const StringBad& st);
};
#endif // !STRINGBAD_H_

stringbad.cpp

#include <cstring>
#include "stringbad.h"
using std::cout;
int StringBad::num_strings = 0;
StringBad::StringBad() {
	len = 12;
	str = new char[12];
	strcpy_s(str, len,"c++");
	num_strings++;
	cout << num_strings << ": \"" << str << "\" object create\n";
}
StringBad::StringBad(const char* s) {
	len = strlen(s);
	str = new char[len + 1];
	strcpy_s(str, len+1,s);
	num_strings++;
	cout << num_strings << ": \"" << str << "\" object create\n";
}
StringBad::~StringBad() {
	cout << "\"" << str << "\" object delete, ";
	num_strings--;
	cout <<"left:" << num_strings << " \n";
	delete[] str;
}
std::ostream& operator<<(std::ostream& os, const StringBad& st) {
	os << st.str;
	return os;
}

main.cpp

#include "stringbad.h"
#include <iostream>
using std::cout;
using std::endl;
int main()
{
    StringBad sb{ "Hello World!" };
}

一切看上去都很合理,程序也能够正常运行~:

image-20210309145117344

# 按值传递问题:

如果把 StringBad 类作为参数按值传递,将会引发程序异常中断。

void call(StringBad bad){
	// do something...
}
StringBad b{ "test" };
call(b);

按值传递会导致对象 badb 内私有成员 char* str 共享一块 new 分配的内存,当 call 执行完毕后需要调用 bad 的析构函数。此时 char* str 已经被释放。然而在退出 main 时,需要对 b 进行释放,此时 char* str 被释放了两次最终导致程序异常。

# 赋值语句问题:

另一个容易产生误会的问题 —— 赋值。把一个 StringBad 对象赋值给另一个 StringBad 对象,会导致程序异常中断。

StringBad sb{ "Hello World!" };
StringBad b = sb; // same: StringBad b = StringBad(sb);

当执行上述赋值操作时,编译器将自动生成一个构造函数 StringBad(const StringBad&); 。该构造函数称为「复制构造函数」。

最终导致 char* str 被释放了两次。此外这两种方式都导致了类变量 static int num_strings; 没有被正常的更新。

# 特殊成员函数

C++ 编译器会在没有定义如下函数的情况下,自动生成默认的函数定义。

  • 默认构造函数
  • 默认析构函数
  • 复制构造函数
  • 赋值运算符
  • 地址运算符
  • 移动构造函数
  • 移动赋值运算符

# 默认构造函数

如果没有定义任何构造函数,那么 C++ 将创建默认构造函数:

StringBad::StringBad(){} // 不接受参数

带参数的构造函数也可以是默认构造函数,只要所有的参数都有默认值:

StringBad::StringBad(int len=10){}

但是默认构造函数只能存在一个。

# 复制构造函数

复制构造函数用于将一个对象复制到新创建的对象中。复制构造函数的原型通常如下:

ClassName::ClassName(const ClassName &);
# 何时被调用❓
StringBad old {"old string bad"};
StringBad new_1(old); // #1 call StringBad::StringBad(const StringBad &); 
StringBad new_2 = old;	// #2 call StringBad::StringBad(const StringBad &); 
StringBad new_3 = StringBad(old);	// #3 call StringBad::StringBad(const StringBad &);
String* p_new_4 = new StringBad(old);	// #4 call StringBad::StringBad(const StringBad &);

其中 #2#3 可能在调用复制构造函数时生成一个临时对象,然后把临时对象赋值给新创建对象(取决于实现细节)。

C++ 会在任何生成临时变量的地方调用复制构造函数来创建临时变量:

  • 临时存储的临时变量 a = b + c 其中 b + c 会生成临时变量。
  • 参数是值传递。
  • 作为返回值返回。
# 有何作用❓

默认复制构造函数会逐个复制「非静态成员」,复制的是成员的值(浅复制)。

如果成员本身就是类对象,那么会用该类的「复制构造函数」复制成员对象。

image-20210309160327701

# 如何解决刚才 StringBad 的问题

# 定义一个显式复制构造函数

通过深拷贝的方式,解决同一个 const char* str 被释放多次的问题。

StringBad::StringBad(const StringBad& st) {
	num_strings++;
	len = st.len;
	str = new char[len + 1];
	strcpy_s(str, len + 1, st.str);
	cout << num_strings << ": \"";
	cout << str;
	cout << "\" deepcopy object create\n";
}

image-20210309160929677

如果类中包含了一个 new 创建的对象,那么应该为其自定义一个复制构造函数,用来复制 new 所指向的数据,而非地址。

# 定义一个赋值运算符

你可能就会问,既然已经有了复制构造函数,那么为什么还需要定义赋值运算符呢?

因为赋值构造函数会在对象初始化的时候调用,但如果对象已经初始化完成了,再通过 = 赋值时,就会调用赋值运算符函数。

StringBad sb{ "Hello World!" }; 
StringBad s = sb; //is ok call 「复制构造函数」
sb = s; //is fail call 「赋值运算符函数」

「赋值运算符」的原理和「复制构造函数」类型,但是还有更多的细节需要注意:

  • 由于对象已经被初始化,在赋值前,需要清理原有的数据。
  • 函数应该避免赋值给自身,否则在对象赋值前,将会被释放。
  • 函数需要返回一个对象的引用。
StringBad& StringBad::operator=(const StringBad& st) {
	if (this == &st)return *this;
	delete[] str;
	len = st.len;
	str = new char[len + 1];
	strcpy_s(str, len + 1, st.str);
	return *this;
}

# 定义其他成员函数来扩展 StringBad 的功能

# 比较成员函数

public:
    friend bool operator<(const StringBad& st_1, const StringBad& st_2);
    friend bool operator==(const StringBad& st_1, const StringBad& st_2);
    friend bool operator>(const StringBad& st_1, const StringBad& st_2);
bool operator<(const StringBad& st_1, const StringBad& st_2) {
	return bool(std::strcmp(st_1.str, st_2.str) < 0);
}
bool operator==(const StringBad& st_1, const StringBad& st_2) {
	return bool(std::strcmp(st_1.str, st_2.str) == 0);
}
bool operator>(const StringBad& st_1, const StringBad& st_2) {
	return st_2 < st_1;
}

# 使用中括号访问字符

定义操作符函数 []

public:
	char& operator[](int i);
char& StringBad::operator[](int i) {
	return str[i];
}

# 静态类成员函数

在类申明中定义静态类函数,该函数可以直接通过类名调用,不需要类对象。

public:
	static int HowMany() { return num_strings; }
StringBad::HowMany();

# 赋值运算符重载

假设需要通过 getline() 读取字符并且构建 StringBad 对象。常规做法如下:

StringBad sb;
char temp[40];
cin.getline(temp, 40);
name = temp;

还可以更简化~,使用 >> 来做:

// define
public:
	friend std::istream& operator>>(std::istream& is, StringBad& st);
// come true
std::istream& operator>>(std::istream& is, StringBad& st) {
	char temp[40];
	is.get(temp, 40);
	if (is)st = temp;
	while (is && is.get() != '\n') continue;
	return is;
}
// use
StringBad b;
cin >> b;

# C++ 空指针

C++98 中,0 既可以表示数值,也可以表示空指针。这往往很难区分,部分程序会用 NULL 来代表空指针(C 语言的宏), C++11 提供了更好的方法 —— nullptrstr = nullptr; // C++ 11 null pointer notation

# 在构造函数中使用 new 时应注意的事项

  • 如果在构造函数中使用 new 来初始化成员,则应该析构函数中 delete
  • newdelete 必须互相兼容。 new 对应于 deletenew[] 对应于 delete[]
  • 如果存在多个构造函数,必须用相同的方式使用 new ,以保证析构函数可以全部适配。
  • delete 操作无论是否带 [] ,都可以对 nullptr 或者 0 使用。所以构造函数可以用 nullptr 或者 new 或者 0 来初始化指针。
  • 如果构造函数中存在 new 成员,请定义「复制构造函数」,用来进行深拷贝。
  • 如果构造函数中存在 new 成员,请定义「赋值运算符函数」,用于检查自我赋值,释放旧数据,完成新数据的深拷贝。

# 有关返回对象的说明

当函数返回对象时,有以下几种返回方式:

  • 返回 const 对象的地址。
  • 返回对象的地址。
  • 返回对象的值。
  • 返回 const 对象。

# 返回 const 对象的地址

使用 const& 作为返回值本质上就是希望能够提高效率,传递引用远比传递值来的高效。

// #1
StringBad test_return_const_addr(const StringBad& s1, const StringBad& s2) {
    if (s1 > s2) return s1;
    return s2;
}
// #2
const StringBad& test_return_const_addr_1(const StringBad& s1, const StringBad& s2) {
    if (s1 > s2) return s1;
    return s2;
}
  • 返回对象 #1 会调用「复制构造函数」,但返回引用不会。
  • #2 所做的工作更少,效率更高。
  • 引用指向的对象应该在函数执行过程中一直存在。
  • s1s2 都是 const 类型,因此返回值也必须为 const 类型。

# 返回指向对象的地址

常见的使用场景是:

  • 重载赋值运算符以实现多次赋值。 s1 = s2 = s3;
  • 重载 << 运算符配合 cout 一起食用😄。 cout << s1 << s2 << s3 << endl;

# 返回对象

如果被返回的对象在函数接受后将会被销毁,那么返回其引用将没有任何意义。这种情况下通常返回对象。

StringBad s_1{"string1"};
StringBad s_2{"string2"};
StringBad s_3 = s_1 + s_2; 
StringBad StringBad::operator+(const StringBad& s){
    return StringBad(str + s.str);
}

# 返回 const 对象

上述操作虽然能够让你使用 + 操作符很好的完成计算。但是也会有一些不好之处:

s_1 + s_2 = "hello world"; // 这条语法在 返回对象 时同样适用

为此,返回 const 对象将可以避免上述情况的发生。

# 使用 new 初始化对象

通常情况下 ClassName *p = new ClassName(value); 将会调用如下构造函数 ClassName(ValueType) ,或者是 ClassName(const ValueType&); ,这取决于函数如何定义。另外,如果没有二义性问题的情况下: ClassName *p = new ClassName; 将会执行默认构造函数 ClassName();

# 再谈 new 和 delete

  • 如果对象是动态变量,当退出动态变量的作用域时,将自动调用该对象的析构函数。
  • 如果对象时静态变量,则将在程序结束时调用对象的析构函数。
  • 如果对象是用 new 创建的,则必须显示的调用 delete 对对象进行回收。

image-20210310155848709

# 指针和对象小结

  • 使用常规表示法来声明指向对象的指针: String *p;
  • 可以将指针初始化为指向已有的对象: String *p = &array[0];
  • 可以使用 new 来初始化指针: String *p = new String("hello world");
  • 对类使用 new 来进行初始化,将调用类的构造函数:
String *p = new String;	// String()
String *p = new String("hello world");	// String(const char*)
String *p = new String(&array[1]);	// String(const String&)
  • 可以使用 -> 运算符通过指针访问类方法。 p->length();

  • 可以通过 * 获取指针所指向的对象。 (*p).str;

image-20210310160741614

image-20210310160753266

# 其他类的操作

# 嵌套结构和类

在类的声明中声明的结构、类或枚举被称为是被嵌套在类中。其作用域为整个类。这种声明本身不会创建实例,只是定义了一个类型。如果该声明被定义为 private 的,那么只能在类中使用。如果是 public 的,则可以通过类对象进行访问。

typedef int Item;
class Queue {
private:
    // node scope in class
	struct Node { 
		Item item;
		struct Node* next;
	};
	enum{Q_SIZE = 10};
	// member
	Node* front;
	Node* rear;
	// ...
};

# 成员初始化列表的语法

在构造函数后面,可以接上需要初始化的成员变量。但有几点需要注意:

  • 该格式只能用于构造函数。
class Cls {
private:
	int m;
	int n;
	int a;
	int b;
	int c;
public:
	Cls(int, int);
};
// init a = _n, b = 1, c = _n*_n
Cls::Cls(int _n, int _m) :a(_n), b(1), c(_n* _n) {
	m = _m;
	n = _n;
}
  • 非静态 const 数据成员必须用这种方式初始化。(C++11 之前是)
class CCls {
private:
	const int a;
public:
	CCls();
};
CCls::CCls() : a(10) {
}
  • 引用数据成员必须用这种方式初始化。
class Clls {
private:
	Cls cls;
public:
	Clls();
};
// 初始化引用数据 cls 必须采用该方法
Clls::Clls():cls(1, 2) {
}

# 初始化顺序

当初始化列表包含多个项目时,这些项目的被初始化顺序为它们被声明的顺序,而并非初始化列表中的顺序。

# C++11 的类内初始化

C++11 支持您以更直观的方式进行初始化。

class Cls {
private:
	int m = 1;
	int n = 1;
	//...
}
Cls::Cls():m(1), n(1) {...} // 两者等效