Table of Contents:

类的定义和对象的创建

类只是一个模板(Template),编译后不占用内存空间,只有在创建对象以后才会给成员变量分配内存。
使用 new 在堆上创建出来的对象是匿名的,没法直接使用,必须要用一个指针指向它,再借助指针来访问它的成员变量或成员函数。
用new要自己delete释放,用不好很危险,用好了功能强大,因为他可以赋值给全局的变量,一下子从局部变量变成全局变量,还能把对象作为函数返回值。
另外使用多态的时候也要用到new和指针
在一般开发里,常规定义(栈空间)与指针定义(堆空间)的区别不是很大。一般都是建议能不使用指针对象的时候不要使用

类的成员变量和成员函数

在类体中定义的成员函数会自动成为内联函数,在类体外定义的不会。若内联函数变了,引用它的文件得重新编译,若不是内联的,只有函数签名改变了,引用此对象的c++文件才需要需重新编译

类成员的访问权限以及类的封装

public:类内、类外、继承可见
protected:类内、继承可见
private:类内可见

成员变量大都以m_开头,这是约定成俗的写法。
以m_开头 1. 既可以一眼看出这是成员变量,2. 又可以和成员函数中的形参名字区分开

//以 setname() 为例,如果将成员变量m_name的名字修改为name,那么 setname() 的形参就不能再叫name了,得换成诸如name1、_name这样没有明显含义的名字,否则name=name;这样的语句就是给形参name赋值,而不是给成员变量name赋值。
void Student::setname(char *name){
    m_name = name;
}

根据C++软件设计规范,实际项目开发中的成员变量以及只在类内部使用的成员函数都建议声明为 private,而只将允许通过对象调用的成员函数声明为 public。

对象的内存模型

对象所占用的存储空间的大小等于各成员变量所占用的存储空间的大小之和(如果不考虑成员变量对齐问题的话)。
对象的大小只受成员变量的影响,和成员函数没有关系。成员函数放在代码区。
和结构体非常类似,对象也会有内存对齐的问题

函数编译原理和成员函数的实现

C++和C语言的编译方式不同。C语言中的函数在编译时名字不变,或者只是简单的加一个下划线

而C++中的函数在编译时会根据它所在的1.命名空间、2.所属的类、3.参数列表(也叫参数签名)等信息进行重新命名,形成一个新的函数名。这个新的函数名只有编译器知道,对用户是不可见的。对函数重命名的过程叫做名字编码(Name Mangling)

Name Mangling 的算法是可逆的,既可以通过现有函数名计算出新函数名,也可以通过新函数名逆向推演出原有函数名。
Name Mangling 可以确保新函数名的唯一性,只要函数所在的命名空间、所属的类、包含的参数列表等有一个不同,最后产生的新函数名也不同。
成员函数最终被编译成与对象无关的全局函数,如果函数体中没有成员变量,那问题就很简单,不用对函数做任何处理,直接调用即可。
C++规定,编译成员函数时要额外添加一个参数,把当前对象的指针传递进去,通过指针来访问成员变量。

void Demo::display(){
    cout<<a<<endl;
    cout<<b<<endl;
}
// 那么编译后的代码类似于:
void new_function_name(Demo * const this){   //this只能指向当前对象
    //通过指针this来访问a、b
    cout<<this->a<<endl;
    cout<<this->b<<endl;
}

这样通过传递对象指针就完成了成员函数成员变量的关联。这与我们从表明上看到的刚好相反,通过对象调用成员函数时,不是通过对象找函数,而是通过函数找对象
最后需要提醒的是,Demo * const this中的 const 表示指针本身不能被修改,p 只能指向当前对象,而不是被指的对象不能修改。

this指针

static静态成员函数

普通成员函数有 this 指针,可以访问类中的任意成员;而静态成员函数没有 this 指针,只能访问静态成员(包括静态成员变量和静态成员函数)。
静态成员函数的主要目的是访问静态成员,静态成员函数可以通过类来调用(一般都是这样做),也可以通过对象来调用.

static静态成员变量

1) 一个类中可以有一个或多个静态成员变量,所有的对象都共享这些静态成员变量,都可以引用它。
2) static 成员变量和普通 static 变量一样,都在内存分区中的全局数据区分配内存,到程序结束时才释放。这就意味着,static 成员变量不随对象的创建而分配内存,也不随对象的销毁而释放内存。
static成员变量的内存既不是在声明类时分配,也不是在创建对象时分配,而是在类外初始化时分配。反过来说,没有在类外初始化的 static 成员变量不能使用。
3) 静态成员变量必须初始化,而且只能在类体外进行。例如: int Student::m_total = 10;
初始化时可以赋初值,也可以不赋值。如果不赋值,那么会被默认初始化为 0。全局数据区的变量都有默认的初始值 0,而动态数据区(堆区、栈区)变量的默认值是不确定的,一般认为是垃圾值。
4) 静态成员变量既可以通过对象名访问,也可以通过类名访问,但要遵循 private、protected 和 public 关键字的访问权限限制。当通过对象名访问时,对于不同的对象,访问的是同一份内存。

构造函数

什么是默认构造函数
默认构造函数是可以不用实参进行调用的构造函数,它包括了以下两种情况:
- 没有带明显形参的构造函数。
- 提供了默认实参的构造函数。

编译器帮我们写的默认构造函数,称为“合成的默认构造函数”。

构造函数在实际开发中会大量使用,它往往用来做一些初始化工作,例如对成员变量赋值、预先打开文件等。
构造函数没有返回值,因为没有变量来接收返回值,即使有也毫无用处,这意味着:
* 不管是声明还是定义,函数名前面都不能出现返回值类型,即使是 void 也不允许;
* 函数体中不能有 return 语句。

一个类必须有构造函数,要么用户自己定义,要么编译器自动生成。一旦用户自己定义了构造函数,编译器都不再自动生成。

构造函数的重载:
如果有多个重载的构造函数,那么创建对象时提供的实参必须和其中的一个构造函数匹配;反过来说,创建对象时只有一个构造函数会被调用。
调用没有参数的构造函数也可以省略括号
在栈上创建对象可以写作Student stu()或Student stu,在堆上创建对象可以写作Student *pstu = new Student()Student *pstu = new Student,它们都会调用构造函数 Student(), 不同的是加()会进行值初始化,不加会默认初始化。

string *ps1=new string;  //默认初始化为空string
string *ps=new string(); //值初始化为空string
int *pi1=new int;        //默认初始化;*pi1的值未定义
int *pi2=new int();      //值初始化为0;*pi2的值为0

默认初始化和值初始化

  1. 默认初始化(Default Initialization): 默认初始化是指在不提供显式初始值的情况下,对象的成员变量将被初始化为一些未知的值,这些值可能是垃圾值、旧数据或者其他未定义的值。默认初始化不会自动为对象的成员变量赋予有意义的初始状态,这可能导致不稳定的行为。默认初始化适用于自动变量(栈上的对象)和未显式初始化的类成员变量。
    int x; // 默认初始化,x的值是不确定的

  2. 值初始化(Value Initialization): 值初始化是一种初始化方式,它会将对象的成员变量初始化为特定的默认值,这可以是零值、空值或者类类型的默认构造函数产生的值。值初始化可以用以下方式实现:

    • 对于基本数据类型(如整数、浮点数等),值初始化会将其设置为零值。
    • 对于指针类型,值初始化会将其设置为nullptr(C++11起)。
    • 对于类类型,值初始化会调用类的默认构造函数,注意:默认构造函数不会把内置类型或复合类型初始化成0值
    int y = int(); // 值初始化,y的值是0
    double z = double(); // 值初始化,z的值是0.0
    int* p = int(); // 值初始化,p的值是nullptr
    std::string str = std::string(); // 值初始化,调用std::string的默认构造函数

    1. 数组初始化时初始值数量小于维度,剩下的元素会进行值初始化;

    { int array[10] = {123}; } 

    2. 当我们不使用初始值定义一个局部静态变量;

    { static int n; } //n值初始化为0

    3. 形如T()的表达式显示地请求值初始化;

    
         std::string *pia1 = new int[10](); //动态分配10个值初始化为0的int
         std::string *pia2 = new int[10]; //动态分配10个未初始化的int
    }

总结起来,区别在于默认初始化只分配内存而不对其进行初始化,而值初始化会将对象初始化为一些特定的默认值,这些值可能是零、空或者通过类的默认构造函数产生的值。在编写代码时,建议总是显式初始化对象,以避免不确定行为。

理论上: A *pa1 = new A;A *pa2 = new A(); 之间是有差别的,前一个应该不会调用默认构造函数而后一个会。
但是在 GCC 和 VS2010 的实验中发现,这 2 个写法是完全没有区别的,默认的构造函数都被调用了。

构造函数初始化列表

成员变量的初始化顺序与初始化列表中列出的变量的顺序无关,它只与成员变量在类中声明的顺序有关。
初始化 const 成员变量的唯一方法就是使用初始化列表,因为const初始化必须有其引用对象,只能初始化,不能复制。

析构函数

销毁对象时系统也会自动调用一个函数来进行清理工作,例如释放分配的内存、关闭打开的文件等,这个函数就是析构函数。
析构函数没有参数,不能被重载,因此一个类只能有一个析构函数。如果用户没有定义,编译器会自动生成一个默认的析构函数

它们与C语言中 malloc()、free() 最大的一个不同之处在于:用 new 分配内存时会调用构造函数,用 delete 释放内存时会调用析构函数

析构函数的执行时机

1. 在所有函数之外创建的对象是全局对象,它和全局变量类似,位于内存分区中的全局数据区,程序在结束执行时会调用这些对象的析构函数。
2. 在函数内部创建的对象是局部对象,它和局部变量类似,位于栈区,函数执行结束时会调用这些对象的析构函数。
3. new 创建的对象位于堆区,通过 delete 删除时才会调用析构函数;如果没有 delete,析构函数就不会被执行。

new 和 new

MyClass *objArr = new MyClass[5]; // 分配数组的内存并调用每个元素的构造函数
delete[] objArr; // 释放内存并调用每个元素的析构函数

new[]时数组的大小会被编译器记录在某个地方,所以编译器能够在delete[]直接查询出来某个数组的大小

对象指针数组

CTest* pArray[2] = { new CTest(4), new CTest(1,2) };

delete 和 delete

  1. delete 运算符: 当使用 delete 运算符删除一个动态分配的单个对象时,它会调用该对象的析构函数然后释放内存。没有数组维度的寻找,因为你只是删除了一个对象。
  2. delete [] 运算符: 当使用 delete [] 运算符删除一个动态分配的数组时,情况会稍微复杂一些。这是因为编译器需要知道要删除的是一个数组,以便可以正确地调用数组中每个元素的析构函数。但是,由于C++中并没有提供一种内建的方法来存储数组的大小,编译器必须从某个地方获取这个信息。
    为了解决这个问题,通常的做法是在动态分配数组的时候,将数组的大小保存在内存块之前(通常是作为额外的信息)。这样,在执行 delete [] 时,编译器可以从该信息中获得数组的大小,然后依次调用每个元素的析构函数,最后释放整个内存块。
    由于这种需要寻找数组维度的额外步骤,delete [] 运算符相对于 delete 运算符来说,可能会稍微慢一些。这就是为什么在某些情况下,使用 delete 运算符删除单个对象可能会更有效率。
  3. delete [] 单个对象: 关于这一点,C++标准并没有定义 delete [] 用于单个对象的行为,而且这在很多编译器上都是未定义行为。这意味着使用 delete [] 来删除单个对象可能会导致不确定的结果,甚至程序崩溃。正确的做法是,对于单个对象,应该使用普通的 delete 运算符。

数组和多态行为的天生不兼容性:
永远不要把数组和多态扯到一起,他们天生是不兼容的。当你对一个指向派生类的基类指针进行
delete [] pbase; 操作时,它是不会有正确的语意的。
这是由于 delete [] 实际上会使用 vec_delete() 类似的函数调用代替,而在 vec_delete() 的参
数中已经传递了元素的大小,在 vec_delete 中的迭代删除时,会在删除一个指针之后将指针向
后移动 item_size 个位置,如果 DerivedClass 的 size 比 BaseClass 要大的话(通常都是如
此),指针就已经指向了一个未知的区域了(如果 Derived 与 Base 大小相同,那碰巧不会发生
错误, delete [] 可以正确的执行)。

例子:

#include <iostream>

class Base {
public:
    virtual ~Base() {
        std::cout << "Base destructor called" << std::endl;
    }
};

class Derived : public Base {
public:
    ~Derived() override {
        std::cout << "Derived destructor called" << std::endl;
    }
};

int main() {
    Base *array = new Derived[3];
    delete[] array; // 错误:可能会导致未定义行为
    return 0;
}

全局静态全局对象

静态初始化是指在程序启动时,全局对象或静态对象的构造函数在main函数之前就已经被调用。而动态初始化是在对象首次使用时才调用构造函数。
使用静态初始化的全局对象在不同编译单元(源文件)中可能会引发静态初始化顺序的问题,这可能导致不可预测的行为,因为C++标准并没有明确定义不同编译单元中全局对象的初始化顺序。
Google C++编程规范以及像Lippman这样的C++专家建议避免使用需要静态初始化的全局对象,原因包括:
1. 不确定的初始化顺序: C++标准没有对全局对象初始化顺序做出明确规定,因此不同编译器、不同平台上的初始化顺序可能会不同,导致不稳定的行为。
2. 复杂性和维护问题: 全局对象的静态初始化可能会引入复杂性,特别是在大型项目中。难以确定对象之间的初始化顺序可能会导致难以调试和维护的问题。
3. 性能开销: 全局对象的静态初始化可能会增加程序启动时间,因为需要在main函数执行之前就调用构造函数。

为了避免这些问题,一些最佳实践包括:
- 避免使用全局对象,尤其是在不同编译单元中。
- 将对象的生命周期限制在需要的作用域内,避免过早或过晚的初始化。
- 使用局部静态对象(函数内的静态对象),因为它们会在第一次使用时进行动态初始化,避免了全局静态对象的问题。

局部静态对象的例子

#include <iostream>

class Logger {
public:
    static Logger& getInstance() {
        static Logger instance; // 局部静态对象,在第一次使用时构造
        return instance;
    }

    void log(const std::string& message) {
        std::cout << "Log: " << message << std::endl;
    }

private:
    Logger() {
        std::cout << "Logger instance created." << std::endl;
    }

    ~Logger() {
        std::cout << "Logger instance destroyed." << std::endl;
    }
};

int main() {
    Logger& logger = Logger::getInstance();
    logger.log("This is a log message.");

    // 在 main 结束时,Logger 实例会被销毁
    return 0;
}

成员对象和封闭类

成员对象的初始化

一个类的成员变量如果是另一个类的对象,就称之为成员对象。包含成员对象的类叫封闭类(enclosed class)
生成封闭类对象的语句一定要让编译器能够弄明白其成员对象是如何初始化的,否则就会编译错误

成员对象的消亡

封闭类对象生成时,先执行所有成员对象的构造函数,然后才执行封闭类自己的构造函数。成员对象构造函数的执行次序和成员对象在类定义中的次序一致.
当封闭类对象消亡时,先执行封闭类的析构函数,然后再执行成员对象的析构函数,成员对象析构函数的执行次序和构造函数的执行次序相反,即先构造的后析构,这是 C++ 处理此类次序问题的一般规律。

const成员变量和成员函数(常成员函数)

const对象(常对象)

const 也可以用来修饰对象,称为常对象。一旦将对象定义为常对象之后,就只能调用类的const成员(包括 const 成员变量和 const 成员函数)了。
语法:

const  class  object(params);
class const object(params);

当然你也可以定义 const 指针:

const class *p = new class(params);
class const *p = new class(params);

友元函数和友元类( friend关键字)

友元函数

1) 将非成员函数声明为友元函数。

//非成员函数
void show(Student *pstu){
    cout<<pstu->m_name<<"的年龄是 "<<pstu->m_age<<",成绩是 "<<pstu->m_score<<endl;
}

class Student{
public:
    Student(char *name, int age, float score);
public:
    friend void show(Student *pstu);  //将show()声明为友元函数
private:
    char *m_name;
    int m_age;
    float m_score;
};
Student::Student(char *name, int age, float score): m_name(name), m_age(age), m_score(score){ }

2) 将其他类的成员函数声明为友元函数

友元类

不仅可以将一个函数声明为一个类的“朋友”,还可以将整个类声明为另一个类的“朋友”,这就是友元类。
友元类中的所有成员函数都是另外一个类的友元函数。

注意点

类其实也是一种作用域

类其实也是一种作用域,每个类都会定义它自己的作用域。
* 在类的作用域之外,普通的成员只能通过对象(可以是对象本身,也可以是对象指针或对象引用)来访问,
* 静态成员既可以通过对象访问,又可以通过类访问,
* 在类中用typedef 定义的类型只能通过类来访问,类在这里就相当于作用域。
* 函数的返回值类型出现在函数名之前,当成员函数定义在类的外部时,返回值类型中使用的名字都位于类的作用域之外

内部类(嵌套类)

类的本质是一个命名空间
如果一个类定义在另一个类的内部,这个内部类就叫做内部类,也称为嵌套类。注意此时这个内部类是一个独立的类,它不属于外部类,只是包含在外部类的命名空间中。
内部类可以访问外部类的私有成员,但外部类不能直接访问内部类的私有成员。
注意内部类可以直接访问外部类中的static、枚举成员,不需要外部类的对象/类名。
内部类和友元类很像很像。只是内部类比友元类多了一点权限:可以不加类名的访问外部类中的static、枚举成员。其他的都和友元类一样。

内部类的一个常见应用场景是实现一些辅助类或数据结构,这些类在外部类内部具有紧密关联,但不需要在外部类之外使用。内部类有助于代码的模块化和封装。

内部类的使用场景包括:
1. 封装性: 内部类可以更好地实现封装,因为它可以访问外部类的私有成员,同时将相关功能组织在一起。
2. 实现细节隐藏: 内部类可以用于隐藏外部类的某些实现细节,从而提供更清晰的接口。
3. 代码组织: 内部类可以将一些相关的类组织在一起,提高代码的可读性和可维护性。

在创建内部类对象

class A
{
public:
       class B{int o;};
};
int main(int argc, _TCHAR* argv[])
{
   A::B*b=new A::B();
   return 0;
}

sizeof(外部类)=外部类,和内部类没有任何关系。内部类仅是多了外部类的命名空间

内部类可以先在外部类中声明,然后在外部类外定义

class A
{
    private: static int i;
    public: class B;
};
//定义
class A::B{
    public:void foo(){cout<<i<<endl;}   //!!!这里也不需要加A::i.
};
int A::i=3;

class和struct到底有什么区别

C++ 没有抛弃C语言中的struct关键字,其意义就在于给C语言程序开发人员有一个归属感,并且能让C++编译器兼容以前用C语言开发出来的项目。
在编写C++代码时,建议使用 class 来定义类,而使用 struct 来定义结构体

C++ string,C++字符串

string path = "D:\\demo.txt";
FILE *fp = fopen(path.c_str(), "rt");


const char* c;
string s="1234";
c = s.c_str();
cout<<c<<endl; //输出:1234
s="abcd";
cout<<c<<endl; //输出:abcd

上面如果继续用c指针的话,导致的错误将是不可想象的。就如:1234变为abcd
其实上面的c = s.c_str(); 不是一个好习惯。既然c指针指向的内容容易失效,我们就应该把数据复制出来,这就要用到strcpy等函数(推荐)。

char* c=new char[20];
string s="1234";
strcpy(c,s.c_str());
cout<<c<<endl; //输出:1234
s="abcd";
cout<<c<<endl; //输出:1234

data():与c_str()类似,但是返回的数组不以空字符终止.

string s = "1234567890";
for(int i=0,len=s.length(); i<len; i++){
    cout<<s[i]<<" ";
}
//string& insert (size_t pos, const string& str);
string s1, s2, s3;
s1 = s2 = "1234567890";
s3 = "aaa";
s1.insert(5, s3)
//string& erase (size_t pos = 0, size_t len = npos);
string s1, s2, s3;
s1 = s2 = s3 = "1234567890";
s2.erase(5);
s3.erase(53);
// string substr (size_t pos = 0, size_t len = npos) const;
string s1 = "first second third";
string s2;
s2 = s1.substr(66);
size_t find (const string& str, size_t pos = 0) const;
size_t find (const char* s, size_t pos = 0) const;

rfind() 和 find() 很类似,不同的是 find() 函数从第二个参数开始往后查找,而 rfind() 函数则最多查找到第二个参数处
find_first_of() 函数用于查找子字符串和字符串共同具有的字符在字符串中首次出现的位置

C++ string的内部究竟是什么样的?

在C语言中,有两种方式表示字符串:
* 一种是用字符数组来容纳字符串,例如char str[10] = "abc",这样的字符串是可读写的;
* 一种是使用字符串常量,例如char *str = "abc",这样的字符串只能读,不能写。

C++ string 对象知道自己在内存中的开始位置包含的字符序列以及字符序列长度;当内存空间不足时,string 还会自动调整,让内存空间增长到足以容纳下所有字符序列的大小。
 
C++ string 的这种做法,极大地减少了C语言编程中三种最常见且最具破坏性的错误:
* 数组越界;
* 通过未被初始化或者被赋以错误值的指针来访问数组元紊;
* 释放了数组所占内存,但是仍然保留了“悬空”指针。

C++ 标准没有定义 string 类的内存布局,各个编译器厂商可以提供不同的实现,但必须保证 string 的行为一致。采用这种做法是为了获得足够的灵活性

C++ 标准没有定义在哪种确切的情况下应该为 string 对象分配内存空间来存储字符序列。string 内存分配规则明确规定:允许但不要求以引用计数(reference counting)的方式实现。但无论是否采用引用计数,其语义都必须一致。

C++ 的这种做法和C语言不同,在C语言中,每个字符型数组都占据各自的物理存储区。在 C++ 中,独立的几个 string 对象可以占据也可以不占据各自特定的物理存储区,但是,如果采用引用计数避免了保存同一数据的拷贝副本,那么各个独立的对象(在处理上)必须看起来并表现得就像独占地拥有各自的存储区一样
只有当字符串被修改的时候才创建各自的拷贝,这种实现方式称为写时复制(copy-on-write)策略。当字符串只是作为值参数(value parameter)或在其他只读情形下使用,这种方法能够节省时间和空间。

不论一个库的实现是不是采用引用计数,它对 string 类的使用者来说都应该是透明的。遗憾的是,情况并不总是这样。在多线程程序中,几乎不可能安全地使用引用计数来实现

C++ STL 中的 std:string 类以字符 \0结尾么

无规定,但是我认为内部没有理由不以零结尾或不预留结尾零的位置
原因在于c_str()这个函数的调用
这个函数会返回c风格的字符串,是以零结尾的。如果内部不以零结尾或不预留结尾零的位置,那么这个函数的实现会比较低效率,因为意味着要重新分配更大的缓冲区来盛放数据。因此(或还有其他原因),主流实现都会以零结尾或预留结尾零的位置。