CPP的基本的语法与C语言保持一致。这里主要记录C语言中没有的语法。
CPP程序的运行过程
预处理阶段会将 h 文件中的所有内容复制到源文件中。调用编译器可以将源文件编译为汇编,然后变为机器码。
链接过程会将所有的机器码文件进行组合,得到一个完整的程序。这个过程中还会检查所有的机器码文件,其中必须定义一个程序的主入口(即main函数)。
堆栈空间
程序所能利用的内存空间分为堆(Heap)栈(Stack)两种,他们的区别在于:
- 栈空间是自动分配的,用于存储局部变量和函数调用时的上下文信息。栈空间的分配通常发生在函数调用时,每个函数调用都会在栈上分配一个栈帧(Stack Frame),用于存储该函数的局部变量、参数、返回地址等信息。当函数返回时,其栈帧会被销毁,局部变量的生命周期也随之结束。
- 堆空间是动态分配的,用于存储程序运行时动态创建的对象。堆空间的大小理论上没有上限,实际受限于硬件实际资源。且其空间的分配依赖于用户手动进行管理。使用
new
和delete
操作符来分配和释放内存。
变量的声明 与 new & delete
变量的声明语法type var_name [= init_value];
// 整数
char a = 1;
short a = 1000;
int a = 100;
long a = 10003;
// 小数
float b = 3.14f;
double c = 1.545;
// 数组声明与初始化
int arr[100];
char arr[] = "hello";
int arr_2d[2][2] = {{1,2},{3,4}};
// 指针
int* ptr = &a;
float* f = nullptr;
// 引用
int a = 100;
int& b = a;
// 自定义的class 或者 struct
MyClass c = MyClass;
new
和 delete
操作符用于动态创建对象,创建的对象会存放在堆空间中。
new
所生成的对象之后会存放在堆中。它返回一个指向新分配内存的指针。其用法如下:
type* ptr1 = new type; // 构造函数不带参数
type* ptr2 = new type(1, "abc"); // 带有构造函数的初始化
// 释放内存空间
delete ptr1;
delete ptr2;
对于没有使用 new
声明的变量则默认会在栈上创建,当作用域的执行周期结束之后(作用域指在任意的 {}
中),该对象的声明周期也就随之结束了。因此在函数内部声明的变量并不需要手动使用 delete
关键字进行析构删除。
在栈空间上创建对象相比于在堆空间上创建对象速度要快得多。并且在堆上创建对象就必须手动释放内存空间。
new
是一个运算符,且可以进行重载。
数组
动态声明数组的语法为 type arr_name = new type[size];
DANGER
注意:
memset(void *dest, int c, size_t count)
函数是按照字节进行赋值的,也就是说,第二个参数的值不应该大于255,并且这个值会以字节为单位对 dest
所指向的指针进行填充,填充的大小由第三个参数count
决定。
假设执行memset(&i, 1, sizeof(int))
,则i
的值为0x01010101
,而不是1
。
int* pIntArray = new int[10];
float* pFloatArray = new float[5];
char* pCharArray = new char[50]; // 分配一个字符数组的内存,足够存储一个字符串
// 初始化可以使用 memset,但是它只能作用在一维数组
// 其函数声明如下void *memset( void *dest, int c, size_t count );
memset(array, 0, n*sizeof(array));
// 指针数组使用二重指针进行声明,并且需要单独处理每个维度
int** pMatrix = new int*[3]; // 分配一个二维数组的第一维
for (int i = 0; i < 3; ++i) {
pMatrix[i] = new int[4]; // 分配第二维,第二个维度也可以是动态的
memset(pMatrix[i], 0, 4 * sizeof(int))
}
// 释放内存空间
delete [] array;
// 如果是动态创建的多维的数组,则需要手动释放
for (int i = 0; i < 3; ++i)
delete [] pMatrix[i];
命名空间
命名空间(namespace)是一种将程序中的实体(如变量、函数、类等)组织在一起的方式,以避免名称冲突的方式。引用其成员的方法为namespace::member
,具体方式如下:
namespace MyNamespace {
int myVariable = 10; // 变量
void myFunction() { // 函数
std::cout << "Hello from MyNamespace!" << std::endl;
}
}
// 引用命名空间中的成员
int main()
{
MyNamespace::myFunction();
std::cout << MyNamespace::myVariable << std::endl;
}
多个文件可以使用同一个命名空间,在多个文件中向同一个命名空间添加成员。从而将相关的代码组织在一起,又可以将不同功能分散在不同的文件中以提高模块化和可维护性。
// my_namespace.h
#pragram once // 类似于IF ENDIF那一套防止重复定义的东西
namespace MyNamespace {
// 你可以在这里声明函数原型
void function1();
void function2();
}
// f1.cpp
#include "my_namespace.h"
namespace MyNamespace {
void function1() {
std::cout << "Function 1 in MyNamespace" << std::endl;
function2(); // 直接调用另一个文件中定义的 function2
}
}
// f2.cpp
#include "my_namespace.h"
namespace MyNamespace {
void function2() {
std::cout << "Function 2 in MyNamespace" << std::endl;
}
}
可以使用using
关键字简化命名空间的使用
using MyNamespace::myFunction;
myFunction(); // 直接调用,不需要MyNamespace::前缀
using namespace MyNamespace;
myFunction(); // 直接调用,不需要MyNamespace::前缀
匿名命名空间
C++允许使用未命名的命名空间,匿名空间内的成员在其他文件中不可直接访问。
namespace {
int unnamedVariable = 20;
void unnamedFunction() {
std::cout << "Hello from unnamed namespace!" << std::endl;
}
}
类与结构体
类是一个包含数据与处理数据的方法的一个集合。
class Player
{
public: //类中可以出现多个public 和 private,可以将成员和方法分离开来。
ABC = 1;
private:
int m_level; // 使用m_开头的成员约定为是私有的。
public: // 声明下列的成员都是对外部公开的
int x, y;
int speed;
void move(xa, ya)
{
x += xa;
y += ya;
}
}; // 注意声明类的时候结尾需要使用分号结尾
int main()
{
Player player1;
player1.move(1, 1); //调用对象的方法
}
结构体struct
和类class
的差异非常小,其中都可以包含数据、方法。但是结构体本身并没有访问控制的功能,所有的数据和方法都是public
的,但是class
可以控制数据的访问控制,且类中的成员如果不加修饰符,则默认都是private
。
除此之外,class
和struct
并没有明显的差异。在不需要显式的成员访问控制时,使用哪一种可以根据自己的编程喜好来决定。
构造函数
构造函数是类中的一种方法,在每次实例化的时候运行。一般起到初始化变量的作用。构造函数的名称必须与类名完全相同,并且没有返回类型,甚至连void
也不能有。如果类中不指定构造函数,则会默认添加一个默认的构造函数。
DANGER
注意:C++中必须手动初始化所有的基本类型。
class Entity
{
public:
float x, y;
Entity()
{
x = 0.0f;
y = 0.0f;
}
};
int main()
{
Entity e; // 调用构造函数初始化为 0, 0
}
含有参数的构造函数,声明带参数的构造函数。如果构造函数需要多个参数初始化,可以使用构造函数的初始化列表来为成员变量提供初始值。初始化列表在构造函数的参数列表之前,使用冒号:
开始,然后列出成员变量的初始化表达式。参数的形参中可以加入参数的默认值。
DANGER
注意:构造函数初始化列表会按照变量声明的顺序进行初始化,而不是初始化列表书写的顺序进行初始化。
class Entity
{
public:
float x, y;
// 带参数的构造函数
Entity(int x, int y): x(x), y(y)
{
x = 0.0f;
y = 0.0f;
}
// 带默认值的构造函数
Entity(int x = 0.0f):x(x)
{
y = 0.0f;
}
};
int main()
{
Entity e; // 调用构造函数初始化为 0, 0
}
DANGER
注意:如果初始化的变量是一个对象,并且使用的是初始化列表进行初始化,对象的构造函数只会执行一次,否则则会被初始化两次。所以应该尽量使用初始化列表语法初始化对象。
class c1
{
public:
c1(){std::cout << "create c1 " << std::endl; }
c1(int a) {std::cout << "create c1 " <<"with " << a << std::endl;}
};
class c2
{
private:
c1 c11;
public:
c2(int a) // 这种初始化方式初始化c1,但是这会创建两个对象,且有一个对象会编程野指针
{
c11 = c1(a);
}
// 这样只会创建一个对象
c2(int a):c11(c1(a))
{
}
}
析构函数可以看作是构造函数的对应函数,一般用于在对象删除的时候卸载变量以及释放内存。这里的删除包括:
- 栈对象:作用域结束后自动调用析构函数。
- 堆对象:显式调用delete后调用。
析构函数的写法为与类名同名的函数+~
前缀,~class_name()
。
class Log{
public:
Log(){}
~Log(){}
};
DANGER
在使用构造函数时需要注意以下事项:
- 构造函数可以重载,即一个类可以有多个构造函数。
- 析构函数不能被重载。
- 构造函数和析构函数不能声明为虚函数。
- 如果类中有指针成员指向动态分配的资源,必须在析构函数中释放这些资源。
运算符调用构造函数
如果构造函数符合初始化时等号右边传递的数值,则会触发隐式类型转换,编译器会将等号初始化的过程编译为构造函数调用过程。具体来说:
隐式转换只能进行一次。例如有一个类 c1
有一个接收 string
参数的构造函数,则不能调用 c1 = "123"
;作为初始化过程,因为 "123"
是 char*
类型,虽然 string
存在隐式转换的 char*
函数,但是不能触发两次隐式转换。
class MyString
{
char* data;
MyString(const char* cp)
{
data = new char[strlen(cp)+1];
strcpy(data, cp);
}
// 运算符重载
MyString& operator=(const MyString& other)
{
cout << "operator = is invoke!" << endl;
// ....
}
};
// 隐式转换可以发生在任何阶段
void PrintMyString(MyString& s)
{
cout << s << endl;
}
int main()
{
PrintMyString("hello"); // 转换为MyString类型
MyString s = "hello world"; // 这里调用的是构造函数而不是调用重载后的赋值运算符
cout << s.data << endl;
}
explicit 关键字
如果构造函数前面加上了 explicit 修饰,则不会允许进行隐式转换操作
class MyString
{
char* data;
explicit MyString(const char* cp)
{
data = new char[strlen(cp)+1];
strcpy(data, cp);
}
};
int main()
{
MyString s = "hello"; //报错,不能进行隐式类型转换
MyString s("hello"); //正确
}
继承
通过继承,子类可以拥有父类中的所有成员变量以及方法。通过提取多个对象中的共同之处并创建父类,可以有效地减少重复代码。继承的写法如下
class Base
{
public:
int x, y;
Base(x = 0, y = 0) : x(x), y(y){}
void move(int xa, int xy)
{
x += xa;
y += xy;
}
};
class C: public Base
{
public:
say()
{
cout << "hello" <<endl;
}
};
int main()
{
C c1 = C; //创建子类对象
c1.move(1, 2); //调用从父类中继承的方法
c1.say(); //访问子类中的特有方法
cout << c1.x << " " << c1.y << endl; // 获取类的成员
}
静态
静态与非静态成员的的区别在于以下几点:
- 存放的内存区域不同,静态的变量与方法存放在静态代码段中。非静态成员则存放在堆栈中。
- 被静态修饰的全局变量会被限制作用域,在链接阶段只会在本文件中进行寻找,也就是说只能在当前的文件中访问。
- 静态的成员方法可以被直接使用而不需要创建对象。
- 静态的成员方法无法访问非静态的成员对象。
- 静态局部变量:当在函数内部声明一个局部变量时,使用
static
可以使得该变量的生命周期贯穿整个程序的运行期间,而不是每次函数调用时重新初始化。这通常用于计数器或需要跨函数调用保持状态的变量。
void function() {
static int count = 0; // 第一次调用时初始化为0,再次调用则不会再初始化
count++;
cout << "Count: " << count << endl;
}
- 静态全局变量:全局变量前使用
static
关键字可以限制其作用域,使其只能在定义它的文件内部访问,成为文件内部的局部变量。
// file1.cpp
static int globalVar = 0; // 只能在file1.cpp中访问
// file2.cpp
// int value = globalVar; // 错误,globalVar 在这里不可见
- 静态成员变量:静态成员变量属于类本身,而不是类的任何特定对象。所有的对象共享这个变量。
class MyClass {
public:
static int staticVar; // 静态成员变量
};
int MyClass::staticVar = 100; // 定义和初始化,如果不赋初值,则会默认为0
int main() {
MyClass::staticVar = 10; // 直接通过类名访问
MyClass obj1;
MyClass obj2;
obj1.staticVar = 100;
obj2.staticVar = 200;
cout << obj1.staticVar << endl; // 输出200
}
- 静态成员函数:静态成员函数不依赖于类的任何特定对象,因此可以通过类名直接调用,而不需要创建类的实例。它们可以访问静态成员变量,但不能访问非静态成员变量或调用非静态成员函数。
class MyClass {
public:
static constexpr int a = 1; // a 是一个静态常量
static int b; // 定义一个静态变量
static void staticFunction() {
cout << "a is "<< a << endl;
cout << "b is "<< b << endl;
}
};
int MyClass::b; //静态成员变量必须在外部初始化以分配内存空间
int main() {
MyClass::staticFunction(); // 直接通过类名调用
}
单例模式
利用static
可以实现单例设计模式
class Singleton
{
public:
static Singleton& Get()
{
static Singleton instance; //在静态代码段新建一个对象实例,但是限制他的作用域
return instance;
}
void hello()
{
//code
}
};
int main()
{
Singleton ins = Singleton::Get();
}
指针与引用
指针可以理解为是指向内存中某个位置的一个整数,任何的类型,包括类、结构体、基础数据类型都只是对于内存空间内容的抽象指代,目的是方便调用以及理解。
#include <iostream>
int main()
{
// 声明一个指向空地址(地址0)的指针
void* ptr = nullptr;
int var = 8;
void* ptr_to_var = &var;
// *ptr_to_var = 8; //这里会报错,因为直接直接赋值编译器并不知道需要读写多少个字节
*(int*)ptr_to_var = 10; //这样可以通过,因为强制转换并且声明了此处是一个int类型的内存空间
std::cout << var; //值已经改变
}
- 对指针类型的定义并不会影响内存中值的变化,程序只会按照预先设定好的类型进行数据的读取
int a;
double* ptr = (double*) (&a); //合法的
- 数组与指针的关系,数组的本质就是一个指向数组开始位置的指针
char* buffer = new char[8];
memset(buffer, 0, 8); // memset的参数分别为起始地址,填入的值,操作的size
delete[] buffer; // 删除数组,声明数组的时候会保存该指针是什么类型的指针,且指针的大小为8个字节,所以调用delete后会自动的释放相应的空间
如果需要在同一行声明同一个类型的指针变量,则需要在每个变量的前面加上 *
。*
默认只作用一次。
int* a, *b *c;
引用是指针的扩展,其本质还是一个指针,只是写法上略有区别。是编译器提供的语法糖。
引用只能指向一个已经存在的对象;在使用的时候可以将引用当作变量的别名。
使用引用需要注意以下事项:
- 引用指向一个已经存在的对象,且对象不能为空
- 引用必须在生命的时候就初始化,且初始化后就不能再改变引用的对象。
int a = 5;
int* ptr = &a;
int& ref = a;
void Increment(int& value)
{
// 引用类型会自动进行 取指针与解指针的操作
// 等价于 (*value)++;
value++;
}
int main()
{
int a = 0;
// 可以等价为 Increment(&a); 只是编译器帮助完成了这一部分
Increment(a); // 可以正常地增加
}
枚举
枚举用来定于指定范围内的常量,其声明方法如下:
enum Color {
Red = 1, //后续的枚举会自动设置为 2, 3。默认会从0开始
Green,
Blue
}; // 同样要以 ; 作为声明的结尾
int main() {
Color color = Red; // 正确,Red是全局命名空间的一部分
return 0;
}
DANGER
注意
普通枚举定义的成员是直接放入其定义所在的命名空间中的。这意味着,如果一个枚举成员没有被限定,它将直接成为当前命名空间的一部分。因此在使用的时候可能会带来符号的冲突。
class Log
{
enum Color {
Error = 1,
Info,
Warning
};
void Info(char* msg){} // 与枚举中的Info冲突,编译失败。
}
枚举类
为了解决这个问题,C++引入了枚举类,其写法如下。
enum class Color {
Red,
Green,
Blue
};
int main() {
Color color = Color::Red; // 正确,必须使用作用域运算符
// Color color = Red; // 错误,Red不在全局命名空间中
return 0;
}
使用枚举类可以有以下好处:
- 避免命名冲突:由于类枚举的成员不会污染全局命名空间,因此可以避免与其他全局变量或枚举成员发生命名冲突。
- 类型安全:类枚举提供了更强的类型检查,有助于编译时的错误检测。
- 更好的封装:类枚举的成员被封装在类型内部,有助于保持代码的整洁和模块化。
访问控制修饰符
访问控制可以实现面向对象中的封装特性。
C++中的可见性修饰符有三个:
- public: 使用
public
关键字声明的类成员可以在类的外部被访问。这是默认的访问级别,如果一个类成员没有指定访问控制符,它就是public
的。 - private: 使用
private
关键字声明的类成员只能在类的内部被访问。这意味着类的外部代码不能直接访问private
成员,但可以通过类的公共方法来间接访问。 - protected: 使用
protected
关键字声明的类成员可以在类的内部以及任何派生(继承)自该类的子类中被访问。protected
访问级别提供的访问权限比private
更宽松,但比public
更严格。
一个类中可以多次使用同一个访问控制符,从而可以实现不同类别的成员分别放在不同的地方
class Test
{
// 公开变量
public:
int a;
// 私有变量
private
int b;
// 公开函数
public:
void func1(){}
}
const
const
关键字用来定义常量或不可变的变量和对象。常量更多表示的是开发人员与编译器的一种约定。并不是强制约束。
const int x = 10; // 一个不可变的整数常量
需要注意的是 const
与指针的关系。
**const**
** 默认作用于其左边的东西,否则作用于其右边的东西,一般从右往左读。,例如:**
const int * | a pointer to an int constant. | 指针指向的内容是一个常数,不可变 |
---|---|---|
int const * | a pointer to a constant int | 与上面的意思一致 |
int* const | a constant pointer to an int | 指针所指向的空间不可改变,空间内的内容可以变化 |
const int* const | a constant pointer to an int const | 一个常量指针且指针指向的也是指向一个常量,两者都不能改变 |
int const * const | a constant pointer to a const int | 与上面的意思一致 |
int const * const * | a ponter to a pointer const to a constant int. | 二维常量指针,两个指针指向的空间都不能变化,但是指向的变量内容可以改变 ** var = 100; |
const 成员函数
如果一个类的成员函数不会修改成员内的任何变量则可以在它的后面添加 const
关键字帮助编译器检查 函数内部没有因为意外修改内部成员的值。在const
成员函数中,除了mutable
成员变量外,其他所有成员变量都被视为只读的。
DANGER
mutable
关键字用于修饰类的成员变量,表示即使对象是const
类型的,这个成员变量也可以被修改。这通常用于那些虽然不改变对象逻辑状态,但需要在const
上下文中改变的成员变量。
class C1
{
private:
int mVar = 0;
mutable int mVar2 = 1;
public:
void setVar(int v)
{
mVar = v; // 非const成员函数,可以修改成员变量
}
int GetVar() const
{
mVar = 2; // 报错
// 只能修改 mutable修饰的成员
mVar2 = 100;
return mVar;
}
};
运算符重载
前面在构造函数部分已经记录了一部分运算符重载的知识
运算符重载应该遵循意义清晰,易于理解的原则进行,不应该随意改变运算符所包含的意义。
运算符重载的语法与声明普通函数的语法类似。函数名称需要为 oprerator+
、oprerator-
的形式。一般声明为:
<返回值类型> operator<运算符>(<形式参数表>)
运算符重载遵循以下的规则:
<返回值类型>
可以是任何有效类型,不过通常是返回操作类的对象;<运算符>
表示要重载的运算符;<形式参数表>
中的参数个数和重载的运算符操作数的个数有关- 对于一元运算符函数,不用显式声明形参,所需要的形参将由
this
指针提供; - 对于二元运算符函数,只需显示声明右操作数,左操作数则
this
提供;
- 对于一元运算符函数,不用显式声明形参,所需要的形参将由
- 非成员函数的类型进行运算符重载,则两个操作数的形参都需要进行声明。且在必要的时候需要声明该函数为友元函数。
class Complex
{
private:
int x, y;
public:
Complex(int x, int y):x(x), y(y){}
Complex operator+(const Complex& b)
{
Complex c;
c.x = this->x + b.x;
c.y = this->y + b.y;
return c;
}
// 重载后缀表达式 Complex++,前缀表达式使用 Complex operator++(int)表示,int参数的值不需要关心
Complex operator++()
{
this->x++;
this->y++;
// 还可以使用下面的方式调用
operator+(Complex(1, 1));
return *this;
}
}
// 重载重定向输出运算符,且声明为友元函数
friend std::ostream& operator<<(std::ostream& os, const Complex& other)
{
os << other.x << ", " << other.y;
return os;
}