C++之路:类基础、构造析构、拷贝构造函数

目录

前言

面向对象编程有三大特性,分别是封装继承多态 。这些特性在类的语法使用中都得到了充分的体现,我也预计写几篇文章来介绍一下C++的类语法。这是第一篇:类基础。

类基础主要目的是介绍一下类的基础使用,重点在于数据函数的封装。

从结构体到类

对于新手来说 这个概念可能比较陌生,但是提到结构体(struct)对于有C语言基础的人应该比较熟悉。在C++中结构体和类的底层实现几乎完全一致,结构体不仅可以封装函数,甚至还可以继承类。

cpp 复制代码
#include <iostream>
#include <cstring>
using namespace std;    

class Base_Class{
protected:
    int id; //占用大小为4字节的内存空间
public:
    Base_Class(int i=0){
        id = i;
        cout << "Base_Class constructor called." << endl;
        cout << "id = " << id << endl;
    }
};

struct Derived_struct : public Base_Class
{
    Derived_struct(int i) : Base_Class(i) {
        cout << "Derived_Struct constructor called." << endl;
        cout << "id = " << id << endl;
    }
};

int main() {
    Derived_struct obj(10);
    return 0;
}

输出结果为:

bash 复制代码
Base_Class constructor called.
id = 10
Derived_Struct constructor called.
id = 10

是不是对类的感觉亲切了许多?当然在C语言中结构体里是不能封装函数的,也没有继承这个说法 。数据和函数不封装到一起,那么当我处理数据的时候(比如结构体)就要去其它地方找函数(公共声明函数的地方),而不是通过对象直接可以调用处理的函数。

打个比方,就像用勺子吃西瓜:C语言买了西瓜后要到厨房去找勺子才能吃西瓜,而C++封装的特性就是让买西瓜时,西瓜和勺子绑定在一起出售。因而C语言是面向过程编程的,C++是面向对象编程。

当然了,结构体和类底层实现相同,这不意味着在C++中结构体和类能混用。从规范上来说,结构体用于数据聚合,基本上不封装方法,就像C那样 (例如坐标点封装,颜色封装)而类则用于‌对象进行封装‌,包括数据以及相关的方法

类的声明与使用

基础声明

类的基础语法声明如下:

举例说明:

cpp 复制代码
class Box
{
   public:
      double length;   // 盒子的长度
      double width;  // 盒子的宽度
      double height;   // 盒子的高度
   int get_volume(){ //获得盒子的体积
   		return length*width*height;
   }
};

类里面就两样东西:数据+方法

继承声明

继承的语法如下:
class {derived_class} : {access_specifier} {base_class}

其中,derived_class是派生类的名称,base_class是基类名称,二者之间通过: + 访问修饰符连接。访问修饰符 access-specifier 是 public、protected 或 private 其中的一个,如果未使用访问修饰符 access-specifier,则默认为 private继承。

cpp 复制代码
// 基类
class Animal {
xxxx
};
//派生类
class Dog : public Animal {
xxxx
};

数据与函数声明与调用

声明

类内变量与成员函数的声明语法类外一致,值得注意的是成员函数的具体实现可以放在类外。具体方法如下:

  1. 首先在类内声明这个函数:
cpp 复制代码
class Dog {
public:
	void bark(); //类内声明
};
  1. 然后在类外以范围解析运算符(::) + 函数名的形式进行定义:
cpp 复制代码
//类外实现
void Dog::bark(){
	cout << "汪汪汪" <<endl;
}

调用

类的变量和函数的调用方法与结构体类似:

cpp 复制代码
class Dog{
public: 
	int age=10;//数据
	void bark() //方法
	{
		cout << "汪汪汪" <<endl;
	}
};
  1. 如果是类对象实例,使用.运算符访问成员变量和函数方法:
cpp 复制代码
Dog wangcai;
wangcai.age; //访问变量
wangcai.bark(); //访问函数
  1. 如果是类指针,使用->运算符访问成员变量和函数方法:
cpp 复制代码
Dog* wangcai;
wangcai->age; //访问变量
wangcai->bark(); //访问方法

类的访问修饰符

访问修饰符用来控制外界对类内变量以及成员函数(类成员)的访问权限。关键字 public、private、protected 称为访问修饰符。

  1. public(公有)
     该成员变量/方法可以被外部的函数直接访问。这是权限最低的一种,类内、派生类以及类外都能访问 往往用于对外接口上。
  2. protected(保护)
     保护的类成员只能被类内以及派生类访问,类外无法访问。
  3. private(私有)
     如果没有声明访问权限,私有是默认的访问修饰符。是保护程度最高的一种,只能类内函数访问 ,派生类和类外都是不能访问的。往往只用于给内部成员函数数据交换

上面的内容总结来说就是:类内成员函数访问类内的变量/方法时没有任何限制派生类能访问public和protected类外的普通函数就只能访问public下的变量/方法(友元函数除外)

cpp 复制代码
#include <iostream>
using namespace std;

class Example {
public:    // 公有成员
    int publicVar;
    void publicMethod() {
        cout << "Public method" << endl;
    }

private:   // 私有成员
    int privateVar;
    void privateMethod() {
        cout << "Private method" << endl;
    }

protected: // 保护成员
    int protectedVar;
    void protectedMethod() {
        cout << "Protected method" << endl;
    }
};

类对象的内存分布

首先要明确一下,类本身是一个抽象的概念,是不占用物理内存的。例如:

cpp 复制代码
class Dog{
public: 
	int age=10;//数据
	void bark() //方法
	{
		cout << "汪汪汪" <<endl;
	}
};

如果不实例化这个类(Dog wangcai)是不会有内存占用的。提这个点是为了解答后面学习多态时,有的教材会说虚函数表存储在类里面,这样容易引起误解,类就像int 、float那样,如果不实例化是不会创造出内存空间的。

类对象实例的内存分布如下:

  1. 最开头: 虚函数表指针。
  2. 非静态成员变量。(按照声明顺序依次排布)

此外,

  • 对于静态成员变量:存储在全局数据区,不占用类实例内存空间。
  • 对于成员函数:代码存储在代码段,所有对象共享同一份函数代码。
  • 对于虚函数:虚函数实现也和成员函数一样存储在代码段。
  • 对于虚函数表:虚函数表在可执行文件中位于.rodata段(Linux/Unix)或.rdata段(Windows),程序加载后存于内存的‌常量区‌,具有只读属性。

类内数据相关

静态变量

静态变量是类内以static为修饰符定义的变量。要点如下:

  1. 对于类中的静态变量来说,所有对象共享同一个静态变量,修改会影响所有实例。(即存储位置在全局数据区)
  2. 静态变量的生命周期‌在程序启动时初始化,程序结束时销毁。
  3. 静态变量在类内声明 在类外定义
  4. 推荐使用类名访问(推荐):ClassName::staticVar

示例代码:

cpp 复制代码
class Person {
public:
    static int count;  // 静态变量声明
};
int Person::count = 0;  // 必须在类外定义

Person p1;
Person::count++;  // 推荐:通过类名访问
p1.count++;       // 不推荐:通过对象访问(合法但不清晰)

非静态变量

又被称为实例变量,是指类内定义的普通成员变量。有以下特性:

  1. 每个对象独立存储‌,不同对象的实例变量互不影响。(即存储在实例的内存区域)
  2. 生命周期‌:随对象创建而分配,随对象销毁而释放。
  3. 必须通过对象实例进行访问。

举例:

cpp 复制代码
class Person {
public:
    std::string name;  // 实例变量
    int age;           // 实例变量
};
Person p1;
p1.name = "Alice";  // 访问实例变量

类成员函数相关

普通成员函数

要点:

  1. 普通成员函数暗含this指针,可以无需声明使用成员变量。
  2. 成员函数的调用要通过对象实例进行,不能单独拎出来使用。

友元函数

友元函数本质就是类外声明定义的函数,不与类对象绑定,即在类内用friend声明的外部函数,能突破封装性直接访问类的私有和保护成员,使其数据访问权限等同于成员函数(也就是说可以访问类的私有变量与函数)。

要点:

  1. 必须在类内使用friend修饰符进行声明。
  2. 具体的实现放在类外。

示例:

cpp 复制代码
class MyClass {
private:
    int secret;
public:
    friend void peek(MyClass& obj); // 声明友元函数
};
void peek(MyClass& obj) { obj.secret = 42; } // 实现可访问私有成员

构造与析构函数

cpp 复制代码
#include <iostream>
using namespace std;

class MyClass {
public:
    int value;
    
    // 构造函数
    MyClass(int v) {
        value = v;
        cout << "构造函数被调用,value=" << value << endl;
    }
    
    // 析构函数
    ~MyClass() {
        cout << "析构函数被调用,value=" << value << endl;
    }
};

int main() {
    MyClass obj1(10);  // 构造函数调用
    {
        MyClass obj2(20);  // 构造函数调用
    }  // obj2离开作用域,析构函数调用
    
    return 0;
}  // obj1离开作用域,析构函数调用

构造函数

构造函数是一种特殊的成员函数,有以下要点:

  1. 无任何返回值(连void也没有),可以传入参数
  2. 函数名与类名相同。
  3. 在类对象创建时执行一次,主要用来初始化类对象
  4. 不被继承,即子类的构造函数中必须要先手动调用父类的构造函数(无参构造时编译器会自动调用)。

构造函数还有一个初始化列表的语法,可以用来初始化字段。例如上面的构造函数可以改为:

cpp 复制代码
MyClass(int v):value(v){
	cout << "构造函数被调用,value=" << value << endl;
}

如果MyClass继承自BaseClass,初始化列表的语法也可以用来初始化构造函数,可以写成这样下面这样,以符合第四点的要求:

cpp 复制代码
MyClass(int v):BaseClass(Base_args),value(v){
	cout << "构造函数被调用,value=" << value << endl;
};

(初始化列表时,用逗号,隔开各个参数)

析构函数

析构函数与构造函数相呼应,就是类对象在销毁时自动调用的函数。(例如,程序结束时、局部对象离开作用域时)。要点如下:

  1. 无任何返回值(void也没有)以及不传入任何参数!
  2. 函数名为~+类名
  3. 对象销毁时自动执行,用来释放类对象手动创建的内存空间,避免内存泄漏
  4. 同样不被继承,但是自动调用(因为是无参的,编译器会自动调用)。顺序为先调用派生类的析构,再调用基类的析构

拷贝构造函数

为什么需要额外的拷贝构造函数?其实想想就能清楚,一个类对象的创建只能有两种途径:

  1. 自定义。使用默认的构造函数进行初始化。
  2. 从其它对象中复制。这时就要调用拷贝构造函数而不是构造函数进行初始化了

why?拷贝构造函数必须要写吗?为什么之前写的类没有拷贝构造函数也能编译通过?

回答这个问题首先要了解一下浅拷贝和深拷贝。

其实C++中数据无非被分为两种:普通变量和指针变量 ,对于浅拷贝来说,执行的就是简单的赋值操作,不区分指针和值,这样也就会造成一种情况:对象A里有个指针p,p指向某个地址。如果此时简单的使用Class B = A这样初始化对象B就是浅拷贝。那么对象B内的指针p的值和A当中指针p的值是一样的(浅拷贝只是简单赋值),这样就会造成对象A和B的指针p指向同一块内存空间,如果B中的p改动了指向的变量(*p),那么A中p指向的值也会随之改变,在某些情况下这是很危险的。(即浅拷贝会造成多个指针指向同一个内存地址的情况 )。

对于深拷贝来说,就会区分普通变量和指针变量。对于普通变量,如int,float等就直接把值复制过去就行了。对于指针变量则会为指针成员分配新内存并复制内容 ,但是这一过程必须要手动实现,这就依赖我们的拷贝构造函数了!

下面从使用场合、语法规则两方面总结拷贝构造函数:

  1. 使用场合:需要以一个已经实例化的类对象为基础,创建一个新的类对象(所谓构造 ),并且类的成员变量中有指针类型 时,需要自定义拷贝构造函数为指针成员分配新内存,并将值赋值到新内存空间中(所谓深拷贝 )。

    若未显式定义拷贝构造函数,编译器会生成默认拷贝构造函数执行‌浅拷贝‌(逐成员复制值)。拷贝构造二者缺一不可,如果仅仅是普通的赋值,例如B = A,那么默认触发浅拷贝,此时如果需要深拷贝应该要在类内重载运算符=

  2. 语法规则:

    定义:

Class_name(const Class_name& obj)

简单来说就是构造函数的基础上,固定传入的参数必须为同类对象的‌常量引用‌(ClassName(const ClassName& obj)),若使用值传递(ClassName(const ClassName obj))会导致无限递归调用。

使用:

MyClass obj2 = obj1; // 隐式调用,此时=表示初始化而非赋值

MyClass obj3(obj1); // 显式调用

示例:

cpp 复制代码
class DeepString {
public:
    char* data;
    DeepString(const DeepString& other) {
        data = new char[strlen(other.data) + 1]; //重新分配地址
        strcpy(data, other.data);  // 复制内容而非指针
    }
};

总结

拷贝和构造缺一不可:

  1. 少了拷贝就会导致多个指针指向同一个地址。
  2. 少了构造就是普通的赋值操作,是在已存在对象间进行赋值(a = b)不属于根据一个已有对对象初始化一个新对象。
相关推荐
哎呦你好2 分钟前
【CSS】Grid 布局基础知识及实例展示
开发语言·前端·css·css3
一入JAVA毁终身14 分钟前
处理Lombok的一个小BUG
java·开发语言·bug
Hellyc36 分钟前
JAVA八股文:异常有哪些种类,可以举几个例子吗?Throwable类有哪些常见方法?
java·开发语言
一梦浮华38 分钟前
自学嵌入式 day30 IPC:进程间通信
linux·运维·服务器
CH_Qing41 分钟前
【udev】关于/dev 设备节点的生成 &udev
linux·前端·网络
电脑能手1 小时前
遇到该问题:kex_exchange_identification: read: Connection reset`的解决办法
linux·ubuntu·ssh
2301_803554521 小时前
c++中的绑定器
开发语言·c++·算法
海棠蚀omo1 小时前
C++笔记-位图和布隆过滤器
开发语言·c++·笔记
snoopyfly~1 小时前
Ubuntu 24.04 安装配置 Redis 7.0 开机自启
linux·redis·ubuntu
精英的英1 小时前
在Ubuntu 24.04主机上创建Ubuntu 14.04编译环境的完整指南
linux·运维·ubuntu