类与对象(1)

1.类的定义

1.1 类定义格式

  • class为定义类的关键字,Stack为类的名字,{}中为类的主体,注意类定义结束时后面分号不能省略。类体中内容称为类的成员:类中的变量称为类的属性或成员变量;类中的函数称为类的方法或者成员函数。
  • 为了区分成员变量,一般习惯上成员变量会加一个特殊标识,如成员变量前面或者后面加_或者m 开头,注意C++中这个并不是强制的,只是一些惯例,具体看公司的要求。
  • C++中struct也可以定义类,C++兼容C中struct的用法,同时struct升级成了类,明显的变化是 struct中可以定义函数,一般情况下我们还是推荐用class定义类。
  • 定义在类面的成员函数默认为inline。
#define _CRT_SECURE_NO_WARNINGS 1
#include <assert.h>
#include <stdlib.h>
#include <iostream>
using namespace std;
 
class Stack
{
 
public:
	// 成员函数 
	void Init(int n = 4)
	{
		array = (int*)malloc(sizeof(int) * n);
		if (nullptr == array)
		{
			perror("malloc申请空间失败");
			return;
		}
		capacity = n;
		top = 0;
	}
	void Push(int x)
	{
		// ...扩容 
		array[top++] = x;
	}
 
		int Top()
	{
		assert(top > 0);
		return array[top - 1];
	}
	void Destroy()
	{
		free(array);
		array = nullptr;
		top = capacity = 0;
	}
 
private:
	// 成员变量 
	int* array;
	size_t capacity;
	size_t top;
}; // 分号不能省略 
 
int main()
{
	Stack st;
	st.Init();
	st.Push(1);
	st.Push(2);
	cout << st.Top() << endl;
	st.Destroy();
	return 0;
}
#include <iostream>
using namespace std;
 
class Date
{
public:
	void Init(int year, int month, int day)
	{
		_year = year;
		_month = month;
		_day = day;
	}
private:
	// 为了区分成员变量,⼀般习惯上成员变量 
	// 会加⼀个特殊标识,如_ 或者 m开头 
	int _year; // year_ m_year
 
	int _month;
	int _day;
};
 
int main()
{
	Date d;
	d.Init(2024, 3, 31);
	return 0;
}
#include <iostream>
using namespace std;
 
// C++升级struct升级成了类 
// 1、类⾥⾯可以定义函数 
// 2、struct名称就可以代表类型 
// C++兼容C中struct的⽤法 
 
typedef struct ListNodeC
{
	struct ListNodeC* next;
	int val;
}LTNode;
 
// 不再需要typedef,ListNodeCPP就可以代表类型 
 
struct ListNodeCPP
{
	void Init(int x)
	{
		next = nullptr;
		val = x;
	}
	ListNodeCPP* next;
	int val;
};
 
int main()
{
	return 0;
}

这个确实没啥好讲的 

1.2访问限定符

访问限定符是C++中控制类成员(属性和方法)访问权限的关键机制,它决定了类的哪些成员可以被外部访问,哪些需要保护或隐藏。C++中有三种访问限定符:publicprotectedprivate

访问权限作用域从该访问限定符出现的位置开始直到下⼀个访问限定符出现时为止,如果后面没有
访问限定符,作用域就到 }即类结束。

public(公共权限)

  • 定义:由 public 修饰的成员在类的外部可以直接访问。
  • 特点
    • 类的用户可以直接访问和使用 public 成员。
    • public 成员通常被用作提供操作对象的接口,比如访问和修改属性的方法。
  • 应用场景
    • 公共接口,例如类的构造函数、成员函数、辅助工具函数等。

 举个例子

class Demo {
public:
    int x;          // 公共属性
    void PrintX();  // 公共方法
};
Demo d;
d.x = 10;           // 直接访问
d.PrintX();         // 直接调用

protected(保护权限)

  • 定义:由 protected 修饰的成员在类外不能直接访问,但可以被该类的派生类访问。
  • 特点
    • 适合需要对派生类开放但不对外部用户开放的成员。
    • 派生类中通过继承访问权限,具体规则因继承方式(public、protected 或 private)不同而变化。
  • 应用场景
    • 内部逻辑依赖,子类需要使用基类中定义的某些功能时。

示例

class Base {
protected:
    int y;          // 保护成员
};
class Derived : public Base {
public:
    void SetY(int val) { y = val; } // 派生类可以访问
};

private(私有权限)

  • 定义:由 private 修饰的成员只能在类的内部访问,外部用户或派生类均不能直接访问。
  • 特点
    • 最严格的权限,用于保护对象的核心数据和实现细节。
    • 需要通过 publicprotected 方法来间接访问。
  • 应用场景
    • 成员变量通常设置为 private,避免外部随意修改,保证封装性和安全性。

示例

class Demo {
private:
    int z;          // 私有成员
public:
    void SetZ(int val) { z = val; } // 提供修改接口
};
Demo d;
// d.z = 10;       // 错误,不能直接访问私有成员
d.SetZ(10);        // 正确,通过接口修改

1.3 类域

类作用域是指类的成员(包括成员变量和成员函数)在类内的访问范围。在类内定义的所有成员,都会被限制在类作用域中,外部不能直接访问私有或保护成员,必须通过指定作用域和访问权限来使用。当在类体外定义成员函数时,需要使用 :: 作用域操作符,明确表明该成员函数属于哪个类的作用域。

  • 类作用域的影响:作用域主要影响编译时的查找规则。
  • 如果在类体外定义成员函数时未指明所属类域,例如 Init,编译器会将其当作全局函数处理,此时无法找到如 array 等类成员的声明或定义,就会导致编译报错。
  • 使用作用域操作符明确 Init 属于类 Stack 后,编译器会在类的作用域中查找 array 等成员的定义,从而正确解析代码。
class Stack {
private:
    int* array; // 类成员
public:
    void Init(); // 成员函数声明
};

// 在类体外定义成员函数时,需要使用作用域操作符::
void Stack::Init() {
    array = new int[10]; // 正确,编译器会在 Stack 类域中找到 array 的定义
}

// 如果未指定作用域操作符,编译器会将 Init 视为全局函数,导致报错。
void Init() {
    array = new int[10]; // 错误,编译器找不到 array 的定义
}

不过可以讲详细点

  • 成员函数的定义
    在类外定义成员函数时,必须使用 类名:: 来指定该函数属于哪个类。
class Stack {
public:
    void Init();  // 声明
};

void Stack::Init() {  // 定义时需要加类名::,表明 Init 属于 Stack
    // 函数实现
}
  • 访问静态成员
    静态成员属于类本身,而不是某个对象,因此需要通过 类名:: 来访问。
class Demo {
public:
    static int count;  // 静态成员声明
};

int Demo::count = 0;   // 通过类名::定义静态成员
Demo::count++;         // 通过类名::访问静态成员
  • 全局函数的区分
    当类成员与全局函数或变量重名时,可以使用 :: 明确区分。
    int value = 10;  // 全局变量
    
    class Demo {
    public:
        static int value;  // 静态成员
    };
    
    int Demo::value = 20;
    
    void Example() {
        int local = value;        // 使用全局变量
        int member = Demo::value; // 使用类静态成员
    }
    

编译器的查找规则

  • 在类外定义成员函数时,编译器需要明确知道它属于哪个类,使用 类名:: 可以告诉编译器:

    • 当前定义的函数是该类的成员。
    • 如果函数体内调用其他成员或变量,编译器会自动到类的作用域中查找。
  • 如果不使用 类名::,编译器会将该函数视为全局函数,导致无法识别类中的成员。

2.实例化 

2.1 实例化的概念

实例化是面向对象编程(OOP)中的一个核心概念,指的是根据类定义创建具体对象的过程。通过实例化操作,类从一个抽象的模板变成可以实际使用的对象。

  • 类的作用:类是对对象的一种抽象描述,类似于一个模型。类中定义了成员变量和成员函数,但这些成员变量在类的定义中只是声明,并没有分配实际的内存空间。
  • 实例化的过程:只有当类被实例化为对象时,才会在内存中为这些成员变量分配空间。
  • 多实例化对象:一个类可以实例化出多个对象,每个对象占用实际的物理空间,用于存储该类的成员变量。

 或者说,类就像建筑的设计图,设计图规定了房间的数量、大小、功能等,但设计图本身没有实体,不能用来住人。只有按照设计图建造出房子后,房子才能具备功能。同样,类本身不能存储数据,只有通过实例化生成对象,分配了物理内存后,才能用于存储数据和操作。

#include <iostream>
using namespace std;

// 类的定义
class Car {
public:
    string brand;    // 属性
    int speed;

    // 成员函数
    void display() {
        cout << "Brand: " << brand << ", Speed: " << speed << " km/h" << endl;
    }
};

int main() {
    Car myCar;       // 实例化对象
    myCar.brand = "Toyota";  // 设置属性
    myCar.speed = 120;
    myCar.display(); // 调用对象的行为
    return 0;
}

 步骤分析

  1. 定义类 Car:定义了一个模板,包含 brandspeed 属性,以及 display 函数。
  2. 实例化对象 myCar:通过 Car myCar 创建了一个 Car 类的实例。
  3. 使用对象:通过点操作符(.)访问和设置对象的属性,并调用它的成员函数。

实例化背后的原理

  • 分配内存:实例化对象时,系统会为对象的成员变量分配内存,每个对象有独立的成员变量。
  • 构造函数调用:如果类定义了构造函数,实例化时会自动调用构造函数来初始化对象。
  • 作用域:对象在其作用域结束后,会自动释放内存,调用析构函数(如果有)。

实例化的特点

  • 多实例独立性:多个对象实例之间互不影响,各自管理自己的属性。

    Car car1, car2;
    car1.brand = "Honda";
    car2.brand = "BMW";
    

    car1car2 是两个独立的实例。

  • 与类的关系:对象通过类的定义共享相同的行为,但属性值可以不同。

  • 对象管理生命周期:对象的内存和生命周期由程序控制,当超出作用域时会被销毁。

2.2 对象大小 

分析⼀下类对象中哪些成员呢?类实例化出的每个对象,都有独立的数据空间,所以对象中肯定包含成员变量,那么成员函数是否包含呢?
  • 成员变量

    • 对象中会包含类的成员变量,因为这些变量需要为每个对象分配独立的存储空间,用来存储各自的数据。
  • 成员函数

    • 成员函数本质上是一段编译后的指令,不能直接存储在对象中。这些指令通常存储在程序的代码段中,而不是在对象的物理内存中。
    • 如果对象中需要存储成员函数,则只能存储成员函数的指针。
  • 是否需要存储函数指针?

    • 不需要。假设有两个对象 d1d2,它们共享相同的成员函数指针(例如 InitPrint 的指针是一样的)。如果在对象中重复存储这些指针,显然会造成浪费。比如,若用类实例化出 100 个对象,则每个对象都重复存储成员函数指针 100 次,极不高效。
  • 编译器的优化

    • 函数指针是固定的地址,编译器在编译链接时已经解析出函数的地址。调用成员函数时,编译器会将其编译为直接调用地址的汇编指令 [call 地址],而不是在运行时查找函数地址。
  • 动态多态的例外情况

    • 如果涉及动态多态(如通过虚函数实现的),则需要在运行时确定调用的具体函数地址。为此,编译器会在对象中增加一张虚函数表指针(vptr),用于指向虚函数表。这是动态多态下对象中唯一需要存储函数地址的情况。

上⾯我们分析了对象中只存储成员变量,C++规定类实例化的对象也要符合内存对齐的规则。

内存对齐规则如下:

  1. 第一个成员的地址

    • 第一个成员总是放在结构体偏移量为 0 的地址处。
  2. 其他成员的对齐要求

    • 其他成员变量的起始地址需要对齐到某个整数倍地址处。这个整数倍称为 对齐数
    • 对齐数 = 编译器默认的对齐数成员大小 之间的较小值。
    • 在 Visual Studio 中,默认对齐数为 8。
  3. 结构体总大小

    • 结构体的总大小需要对齐到其最大对齐数的整数倍。
    • 最大对齐数是所有成员变量对齐数的最大值,或者编译器默认对齐数(取较小者)。
  4. 嵌套结构体的特殊规则

    • 如果结构体中嵌套了另一个结构体,那么嵌套的结构体本身需要对齐到它的最大对齐数的整数倍。
    • 整个结构体的大小也必须对齐到所有成员最大对齐数的整数倍,包括嵌套结构体的对齐数。

举例

假设在 Visual Studio 编译器中:

struct A {
    char c;       // 对齐数为1
    int i;        // 对齐数为4
    double d;     // 对齐数为8
};
  • 对齐过程

    • c 的偏移量为 0,占用 1 字节,但需要对齐到 4 字节,后面填充 3 字节。
    • i 放在偏移量 4 处,占用 4 字节。
    • d 放在偏移量 8 处,占用 8 字节。
  • 总大小

    • 最大对齐数为 8,因此整个结构体大小需要是 8 的倍数,最终占用 16 字节。

如果嵌套结构体: 

struct B {
    char c;  // 对齐数为1
    A a;     // 对齐数为8
};
  • 对齐过程

    • c 的偏移量为 0,占用 1 字节,后面填充到 8 字节以对齐 A
    • a 放在偏移量 8 处,占用 16 字节(A 的大小)。
  • 总大小

    • 最大对齐数为 8,因此结构体 B 的大小为 24 字节。

 3. this指针

Date 类中,成员函数 InitPrint 的函数体中没有对不同对象进行区分,但当对象 d1 调用这些函数时,函数如何知道访问的是 d1 对象而不是其他对象呢?这由 C++ 提供的一个隐含的 this 指针解决。

1.this 指针的引入

  • 编译器在编译时,会为类的成员函数默认增加一个隐含的参数,这个参数是一个指向当前对象的指针,称为 this 指针。
  • 比如,Date 类中 Init 函数的真实原型实际上是:
    void Init(Date* const this, int year, int month, int day)
    

2.this 指针的作用

  • 成员函数通过 this 指针访问对象的成员变量。例如,在 Init 函数中,_year = year 实际是通过 this->_year = year 实现的。

3. 隐式处理 this 指针

  • C++ 规定,不能在实参和形参中显式写出 this 指针,因为编译器会自动完成这一部分的处理。
  • 但是,在函数体内,可以显式使用 this 指针来明确表示当前对象。

例如

void Date::Init(int year, int month, int day) {
    this->_year = year;   // 显式使用 this 指针
    _month = month;       // 隐式使用 this 指针,等价于 this->_month = month;
    _day = day;           // 隐式使用 this 指针
}

下面通过三个选择题测试⼀下前⾯的知识学得如何? 

 1.下列程序的编译运行结果是():

#include<iostream>
using namespace std;

class A{
public:
    void Print(){
        cout << "A::Print()" << endl;
    }
private:
    int _a;
};

int main(){
    A* p = nullptr;
    p->Print();
    return 0;
}

A、编译报错              B、运行崩溃            C、正常运行

2.下⾯程序编译运结果是()
#include<iostream>
using namespace std;

class A{
public:
    void Print(){
        cout << "A::Print()" << endl;
        cout << _a << endl;
    }
private:
    int _a;
};

int main(){
    A* p = nullptr;
    p->Print();
    return 0;
}
A、编译报错              B、运行崩溃            C、正常运行
3.this指针存在内存哪个区域的 ()
A. 栈 B.堆 C.静态区 D.常量区 E.对象里面

4. C++和C语言实现Stack对比

⾯向对象三大特性:封装、继承、多态,下⾯的对比我们可以初步了解⼀下封装。
通过下⾯两份代码对⽐,我们发现C++实现Stack形态上还是发生了挺多的变化,底层和逻辑上没啥变化。
  1. 封装的体现
    通过比较两份代码(C++实现的Stack和普通实现),我们发现:

    • 在C++中,数据和函数被集中封装在类中,并通过访问限定符(如publicprivateprotected)进行了访问权限的限制,防止随意直接修改对象的数据。这是封装的一种重要体现,增强了代码的规范性和安全性,避免了无序访问和修改的问题。
    • 这种封装不仅是为了限制访问,还能为管理代码提供更严谨的规范化支持,是面向对象的核心特性之一。
  2. C++的便捷性

    • 缺省参数Init函数可以提供默认参数,简化了函数调用。
    • 隐含的this指针:C++类的成员函数自动传递this指针,避免了手动传递对象地址,代码更加简洁。
    • 类型方便性:C++类的使用不再需要显式地使用typedef来定义类型,通过类名即可方便地定义对象。
  3. 实质上的变化

    • 在初学阶段的C++封装,逻辑和底层实现变化不大,更多是代码形态和规范上的提升。
    • 未来深入学习后,看到C++标准模板库(STL)中的Stack,通过适配器模式实现后,才能真正感受到C++在封装和代码复用上的强大魅力。

封装不仅是形式上的改变,更是一种思维方式的提升,为后续复杂功能的实现打下基础。

C++实现 Stack 

#include <iostream>
#include <vector>

class Stack {
private:
    std::vector<int> data; // 使用动态数组存储栈数据

public:
    // 入栈操作
    void Push(int value) {
        data.push_back(value);
    }

    // 出栈操作
    void Pop() {
        if (!data.empty()) {
            data.pop_back();
        } else {
            std::cerr << "Stack is empty! Cannot pop.\n";
        }
    }

    // 获取栈顶元素
    int Top() const {
        if (!data.empty()) {
            return data.back();
        } else {
            throw std::runtime_error("Stack is empty! No top element.");
        }
    }

    // 判断栈是否为空
    bool IsEmpty() const {
        return data.empty();
    }

    // 获取栈的大小
    size_t Size() const {
        return data.size();
    }
};

int main() {
    Stack stack;
    stack.Push(10);
    stack.Push(20);
    stack.Push(30);

    std::cout << "Stack top: " << stack.Top() << "\n";
    stack.Pop();
    std::cout << "Stack top after pop: " << stack.Top() << "\n";

    return 0;
}

 C语言实现 Stack

#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>

#define MAX_SIZE 100 // 栈的最大容量

typedef struct Stack {
    int data[MAX_SIZE]; // 静态数组存储栈数据
    int top;            // 栈顶指针
} Stack;

// 初始化栈
void InitStack(Stack* stack) {
    stack->top = -1; // 栈为空时,top 指向 -1
}

// 入栈操作
void Push(Stack* stack, int value) {
    if (stack->top >= MAX_SIZE - 1) {
        printf("Stack overflow! Cannot push.\n");
        return;
    }
    stack->data[++stack->top] = value;
}

// 出栈操作
void Pop(Stack* stack) {
    if (stack->top == -1) {
        printf("Stack underflow! Cannot pop.\n");
        return;
    }
    stack->top--;
}

// 获取栈顶元素
int Top(const Stack* stack) {
    if (stack->top == -1) {
        printf("Stack is empty! No top element.\n");
        return -1; // 错误值,真实应用时应避免使用
    }
    return stack->data[stack->top];
}

// 判断栈是否为空
bool IsEmpty(const Stack* stack) {
    return stack->top == -1;
}

// 获取栈的大小
int Size(const Stack* stack) {
    return stack->top + 1;
}

int main() {
    Stack stack;
    InitStack(&stack);

    Push(&stack, 10);
    Push(&stack, 20);
    Push(&stack, 30);

    printf("Stack top: %d\n", Top(&stack));
    Pop(&stack);
    printf("Stack top after pop: %d\n", Top(&stack));

    return 0;
}
  • C++版本

    • 利用了STL的vector容器自动管理内存。
    • 使用了类的封装,操作更加面向对象。
  • C语言版本

    • 需要手动管理内存和栈顶指针。
    • 没有封装,只能通过函数操作结构体。
但是实质上变化不大。等着我们后面看STL 中的用适配器实现的Stack,大家再感受C++的魅⼒。

 

下一章介绍类的默认成员函数中的构造函数、析构函数和拷贝函数 

类和对象 (2)-优快云博客

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值