码农日记 · 壹
Last updated on March 23, 2025 am
Java学习笔记:面向对象三大特征
C语言面向过程,强调步骤和因果关系,耦合度高(一个关系错误整个就错了),扩展力差。
C++既面向过程、又面向对象。
Java面向对象,每个事物都是对象,各自协同形成系统,耦合度低,扩展力强。
面向对象分析:OOA(Object-Oriented Analysis)
面向对象设计:OOD(Object-Oriented Design)
面向对象编程:OOP(Object-Oriented Programming)
面向对象的三大特征:封装(encapsulation)、继承(inheritance)、多态(polymorphism)。
前置知识:类和对象
类:某些特征、性质的总结,抽象的模板。类是具有相同属性(数据、变量)和方法(实现功能的函数)的对象集合。普通公共类Animal的创建:
public class Animal {
...
}
对象:类实例化的具有特定接口的内存块,也叫实例。对象通过类(Animal)来创建:Animal ani = new Animal();
new运算符会在JVM(Java Virtual Machine,Java虚拟机)的堆内存中开辟一块空间,将创建的对象放入堆内存空间中。程序运行时,JVM中的类加载器class loader会先加载类(.class文件),装载到JVM后由解释器将字节码翻译为二进制数据,供操作系统(OS)与硬件交互。
使用new,本质上就是在调用构造器。
保存了内存地址并指向堆内存对象的变量叫引用,通过(引用.对象名)的方式可以访问实例变量(堆内存中存储的对象的实例变量)。其他语言如C++、Python等也可以以此类推。引用相当于C语言中的指针(Java没有指针这个概念)。
构造方法(也叫构造器、Constructor、构造函数)是用来创建对象并对实例变量进行初始化(赋值)的一种特殊方法。无参构造方法是系统默认提供的,也叫缺省构造器,如果用户手动编写了有参构造器,系统就不会再提供缺省构造器了,因此需要加上无参构造器防止出错。构造方法名必须与类名一致,且不需要写返回值类型。
public Animal(形参列表) {
...
}
普通方法带关键字static,static修饰静态方法(或变量),是类相关的,通过(类名.)的形式调用,同一类中(类名.)可以省略。
public static void dosome() {
System.out.println("DOSOME!");
}
实例方法不带static,是对象相关的,通过(引用.)的形式调用。需要用类实例化对象,才能用对象名(引用)调用实例方法或访问实例变量。
public void dosome() {
System.out.println("DOSOME!");
}
封装
封装,就是将各种对数据的操作封在一起,成为不可分割的独立整体,尽可能隐藏内部细节,只保留一些对外的接口与外界发生关系。外部人员不能随便访问,保护内部数据和结构的安全。即:属性“私有化”。
注意,不是用private关键字将属性私有后什么也不管了,这样该属性只能在本类中访问,而不能在其它类中访问了,过度的安全意义不大。正确做法:
public class Person {
private int age;
public int getAge() {
return age;
}
public void setAge(int number) {
if(number<0 || number>130) {
System.out.println("输入年龄不合法!");
return; // 结束方法
}
age = number;
}
}
这种做法的好处是只提供外部接口对类内的属性进行访问和修改,且修改需要满足一定的条件,在一定程度上保证了内部数据的安全,并且屏蔽了复杂、显露了简单。可以在另一个类中进行访问:
public class Test {
public static void main(String[] args) { // 程序入口
Person p = new Person();
System.out.println(p.getAge()); // 读取年龄
p.setAge(10); // 修改年龄为10(年龄合法,修改成功)
}
}
继承
代码封装后形成了独立体,但是独立体之间可能存在继承关系。比如两个类:Animal和Dog,很明显Dog属于(继承)Animal。继承关键字:extends。
子类继承父类,则父类中已经定义的方法,子类可以直接拿来用,实现了代码复用。而且有了继承,多态机制才能发挥作用,方法覆盖才有用武之地。
当多个类共用一些属性时(不同类也可能有相同的一些特性),将这些属性、方法都放在某一个类中作为通用类(父类),让其他类共享,其他类为特定类(子类)。这就是子类继承父类。父类中的属性和方法,子类可以“直接用”,不需要重新定义和编写代码。若B类继承A类,则A类是Superclass,即父类、超类、基类;B类是Subclass,即子类、派生类、扩展类。
class B extends A {
...
}
Java中规定,子类继承父类,除了构造方法外的所有都可以继承。但是private关键字修饰的私有属性在子类中是无法直接访问的。虽然私有属性被继承过来了,但是仍然是属于父类的,子类中访问跟“外界”访问的方式一样,通过对外的接口方法:getter和setter方法。且Java中默认父类(没有用extends的类)都继承跟类Object,这是老祖宗类。继承以后,耦合度变高了。
只要满足“A is a B”这种关系的,比如Dog is a Animal,就可以继承。
子类继承了父类的属性和方法时,若某些方法不符合自己的要求,子类可以重写一遍该方法,此为Override,即方法覆盖或方法重写。此时,子类对象调用该方法,执行的是子类中重写的新方法,就不再是父类中的老方法了。重写的方法除了方法体和返回值类型之外,其他部分必须与父类一直,比如方法名、形参列表等。访问权限不能低于父类,但可以更高,比如父类是protected,重写后是public,这是可行的,但反过来不行。另外,重写后的方法不能比父类的原方法抛出更多异常。
方法覆盖注意事项:只针对方法,与属性无关;私有方法不能被覆盖;构造方法无法被继承,故不存在覆盖这一说;只针对实例方法,静态方法的覆盖是没有意义的(静态方法即便覆盖了,用了(引用.)的形式调用,本质上还是转换为了(类名.)的形式,其实与对象(子类)没有任何关系,也就不存在多态这一说。因此一般认为静态方法不存在方法覆盖)。
扩展:抽象类
在面向对象的概念中,所有的对象都是通过类来描绘的,但是反过来,并不是所有的类都是用来描绘对象的。如果一个类中没有包含足够的信息来描绘一个具体的对象,这样的类就是抽象类。
抽象类除了不能实例化对象之外,类的其它功能依然存在,成员变量、成员方法和构造方法的访问方式和普通类一样。
由于抽象类不能实例化对象,所以抽象类必须被继承,才能被使用。
在Java语言中使用abstract class来定义抽象类。
关于抽象类和接口,请移步:码农日记·贰 > Java:接口vs抽象类。
this和super
this()语句,调用本类(调用者)这个对象的(无参)构造器,只能位于类的第一行。this不需要继承也可以使用,有对象即可。
super()语句,调用父类的(无参)构造器,默认是有这条语句的,只能位于类的第一行。super写在子类中,只有用到了继承,才可以使用。
this和super都只能写在第一行,所以无法共存,只能存其一。
多态
多态是同一个行为具有多个不同的表现形式或形态的能力,即同一行为发生在不同的对象上会产生不同的效果。换句话说,多态就是同一个接口,使用不同的实例而执行不同的操作。
多态可以降低类型之间的耦合关系,具有可替换、可扩充的性质,简单而灵活。多态存在有三个必要条件:继承、方法重写、父类引用指向子类对象。

在Java中对多态的核心表现主要是方法的重写。方法的重写(覆盖),一言以蔽之:同一个父类的方法,可以根据子类的不同而具有不同的表现形式。
多态有两种类型。由子类转向父类,是向上转型;由父类转向子类,是向下转型。不管是向上转型还是向下转型,两个类之间必须是继承关系,即父类与子类。下面是关于多态的实例分析。
向上转型(upcasting):
class Animal {
public void move() // 父类中的实例方法
{
System.out.println("a is moving!");
}
}
class Cat extends Animal {
public void move() // 子类重写了实例方法
{
System.out.println("c is wandering!");
}
}
public class Test {
public static void main(String[] args) {
Animal ac = new Cat(); // 向上转型
ac.move(); // 输出:c is wandering!
}
}
向上转型,即允许父类型引用指向子类型对象。编译时,编译器只知道ac是Animal类型,因此会去Animal.class(类)字节码中寻找move()方法,而Animal中有此实例方法,因此会被找到。找到了以后,编译器认为ac要访问的方法move()存在,编译通过,此为静态绑定。运行时,堆内存中实际创建的其实是Cat对象,不是Animal类型对象,因此通过(引用.)的方式调用执行的move()方法是Cat中的实例方法,此为动态绑定。编译(静态绑定父类的方法)、运行(动态绑定子类型对象的方法)各有一种形态,故名多态。
不管是否发生了向上转型,核心的本质还是在于:你使用的是哪一个子类(new了什么),而且调用的方法是否被子类所覆写了。想一想为什么要使用向上转型?假设父类有N个子类,子类中重写了父类的实例方法,如果没有向上转型,有N个子类就需要定义N个方法接收不同的对象。向上转型可以减少重复代码,且实例化的时候可以根据不同的需求实例化不同的对象,实现参数的统一化。具体的意义用得多了自然就明白了,下面举一个例子进行解释。
// 我们需要写getMessage()方法接收不同子类的对象并调用它们的实例方法print()输出各个子类的相关信息
public void getMessage(Student stu) {
stu.print();
}
public void getMessage(Tescher ter) {
ter.print();
}
// 如果还有很多子类,那么需要写很多的方法
// 使用向上转型进行代码复用,只需写一个方法来接收所有Person类的子类对象,实现了参数的统一化
public void getMessage(Person per) { // 把对象传入,自动进行upcasting
per.print();
}
如果子类中写了独有的方法,向上转型用父类型的引用访问子类型的专有方法时会出错,因为静态绑定失败(父类型中没有此方法)。此时就需要用向下转型进行强制类型转换了。
向下转型(downcasting):
class Animal {
public void move() // 父类中的实例方法
{
System.out.println("a is moving!");
}
}
class Cat extends Animal {
public void move() // 子类重写了实例方法
{
System.out.println("c is wandering!");
}
public void CatchMouse() // 子类特有方法
{
System.out.println("c can catch mouse, can you?");
}
}
public class Test {
public static void main(String[] args) {
Animal ac = new Cat(); // 先向上转型
if(ac instanceof Cat) { // 判断引用指向的对象类型是否匹配,互相匹配才可以强制转换
Cat x = (Cat)ac; // Animal类型的ac与Cat有继承关系,可以强制父类->子类转换,即向下转型
x.CatchMouse(); // 输出:c can catch mouse, can you?
}
}
}
通过对象的向上转型可以实现接收参数的统一,向下转型可以实现子类扩充的调用(一般不操作向下转型,有安全隐患,不过搭配instanceof使用可以提高安全性)。注意:向下转型之前一定要进行向上转型!
知识迁移:Overload与命名空间
Overload,即方法重载,同一个方法名称可以根据参数类型或者参数个数的不同而调用不同的方法体。如果功能相近的几种方法需要各自取名时,未免过于繁琐,且名字太多调用不方便。故而引入方法重载解决这个问题。方法重载使得功能相同或相近的方法可以用同一个名字,利用不同的参数来分别选用不同的方法。触发Overload机制的条件:在同一个类中;方法名相同;参数列表不同。以上三个条件同时满足时,我们认为触发了方法重载机制,比如m() 与 m(int a)
、m(int i, double j) 与 m(double i, int j)
等,下面是一个例子。
// 定义了两种的方法,但都是进行求和运算
int sum(int i, int j){
return i + j;
}
double sum(double i, double j){
return i + j;
}
// 主函数(方法)中通过参数匹配从而进行不同的调用
int a = sum(10,20); // outpit: a == 30
double b = sum(10.0,20.0); // output: b == 30.0
方法覆盖与重载的详细对比点此查看:runnoob: java - override and overload
C++中有一个概念叫:命名空间,是一个由程序设计者命名的内存区域,它定义了一个包含多个变量和函数的范围,使其不会与命名空间以外的任何变量和函数发生重名的冲突。每个程序员可以定义自己的命名空间,多个工程师开发同一个项目时,对程序的管理更加便捷和高效。命名空间的关键字是namespace,命名空间可以相互分隔不同的全局实体,但是命名空间不能同名。在成员名字这个方面,我觉得命名空间与方法覆盖和方法重载有异曲同工之妙。下面是关于命名空间的简单C++示例:
#include <iostream>
// std是输入输出标准的类,它包括了cin和cout成员(内置对象)
// 利用此语句可以直接使用这些成员函数
// 如果没有此语句,那么需要用 std::cin 或者 std::cout 进行使用
using namespace std;
namespace first_field /* 第一个命名空间 */
{
void fun()
{
// cout与流插入运算符 << 搭配使用(cin与流提取运算符 >> 搭配使用)
cout << "第一个命名空间所包含的内容" << endl;
}
}
namespace second_field /* 第二个命名空间 */
{
void fun()
{
cout << "第二个命名空间所包含的内容" << endl;
}
}
// 主函数(程序入口)
int main()
{
namespace FF = first_field; /* 为第一个命名空间起个别名 */
first_field::fun(); /* 调用第一个命名空间中的函数 */
FF::fun(); // 同上
second_field::fun(); /* 调用第二个命名空间中的函数 */
return 0;
}
内存块
数据结构中的内存
内存一般分成以下6个区:
栈区(Stack):编译器自行分配、自行释放,存放局部变量的值、函数(方法)参数、寄存器内容、函数返回的地址等。栈相当于一个受限的线性表,主要用于数据交换、函数调用、断点保护等,空间不大,随用随消。
堆区(Heap):由程序员进行分配、释放,如Java中的new操作。堆相当于一个特殊的完全二叉树,空间大,主要用来存放大量文件,操作类似于链表。
自由存储区:由malloc等进行分配的内存块,用free释放空间。与堆的new生成和delete释放类似。
/* C语言申请内存空间 */
// 顺序表的指针变量
SeqList *L;
// malloc函数可以动态分配内存空间,向操作系统申请(SeqList)类型大小的内存空间,返回第一个字节地址
// (SeqList*)强制转换为地址类型,否则只是干地址,无实际含义
L = (SeqList*)malloc(sizeof(SeqList));
/* Java创建对象 */
Student stu = new Student();
文字常量区:存放常量、字符串。
程序代码区:存放函数的二进制代码。
全局/静态存储区:分配全局、静态变量。
引申:字面量、常量和变量
先看下面的C++代码例子:
int a; // 变量
const int b = 10; // b为常量,10为字面量
string str = "hello world!"; // str为变量,hello world!为字面量
在计算机科学中,字面量(literal)是用于表达源代码中一个固定值的表示法(notation)。几乎所有计算机编程语言都具有对基本值的字面量表示,诸如:整数、浮点数以及字符串,有很多也对布尔类型和字符类型的值支持字面量表示,还有一些甚至对枚举类型的元素以及像数组、记录和对象等复合类型的值支持字面量表示法。
有些数据在程序运行中可以变化或者被赋值,这称为变量。有些数据可以在程序使用之前预先设定并在整个运行过程中没有变化,这称为常量。变量和常量在内存中的存储方式是一样的,只是常量不允许改变,就像只读文件一样。
字面量是指由字母、数字等构成的字符串或者数值,它只能作为等号右边的值出现,相当于真实值。
Java中,成员变量就是定义在类里方法外的变量,也叫全局变量。成员变量又分为:实例变量和类变量。类变量也叫静态变量,即在变量前加static的变量;实例变量也叫对象变量,即没加static修饰的变量。静态变量和实例变量的区别在于:静态变量是所有对象共有的,其中一个对象将它的值改变了,那么其他对象得到的就是改变后的结果;而实例变量则为对象专有,若某一个对象将其值改变,不会影响其他对象。如果局部变量和成员变量的名称相同,那么成员变量被隐藏,即方法内失效,方法中如需要访问该类中的成员变量,则需要加上关键字this。
Java虚拟机JVM中的空间
JVM中主要有三块空间:栈区、方法区、堆区。
当一个Java程序运行时,JVM中的类加载器ClassLoader将硬盘上的xxx.class字节码文件存入方法区中(程序类最先加载),即方法区最先有数据(代码片段)。同时静态变量也会被存储在方法区。
在方法(方法区中的代码片段)被调用时,所需要的内存空间在栈中分配,进栈(或称压栈、入栈)为push,出栈(或称弹栈)为pop。方法调用时为压栈(为方法中的局部变量和运行所需的内存分配空间),方法结束空间释放时是弹栈。
堆区是由程序员对类和对象进行操作时用到的。当使用new运算符创建对象后,JVM的堆内存会开辟一块空间(垃圾回收器主要针对的就是堆内存),将创建的对象以及对象的实例变量存储在其中。
成员变量的存储:没有实例化的成员变量放在栈中,实例化后的对象放在堆中,栈中放的是指向堆中对象的引用地址。成员变量在对象被创建时而存在,当对象被GC(Garbage Collection,垃圾回收机制)回收时消失,生存周期适中。
C++数组与指针
当时大学初学C语言时,就被指针、一维数组和二维数组等相关知识绕了好久。C++的指针与C差不多,这里用几段程序对这个容易混淆的知识点进行总结。
引用为返回值
#include <iostream>
#include <typeinfo>
using namespace std;
int a[] = {2, 6, 8, 9, 10};
/* 把引用作为返回值,与返回指针类似,相当于返回一个指向返回值的隐式指针,可以作为赋值语句的左值 */
int& value(int i)
/* 如果这样定义: int value(int i),执行下面的 value(0)=1 和 value(3)=20 就会报错,提示左值不对 */
{
return a[i];
}
int main()
{
int i;
cout << "Before change:" << endl;
for(i=0; i<5; i++)
{
cout << "a[" << i << "] = " << a[i] << endl;
}
/* 修改数组的值,赋值语句合法 */
value(0) = 1;
value(3) = 20;
/* 输出a(数组)的类型,我的输出的是缩写 */
cout << "type of value's return is: " << typeid(a).name() << endl; // A5_i
/* 输出a(数组)的类型全称,要加上头文件: #include <cxxabi.h> */
cout << abi::__cxa_demangle(typeid(a).name(),0,0,0 ) << endl; // int [5]
cout << "After change:" << endl;
for(i=0; i<5; i++)
{
cout << "a[" << i << "] = " << a[i] << endl;
}
return 0;
}
1D and 2D Array
#include <iostream>
using namespace std;
void test_1d(); /* 一维数组辨析 */
void test_2d(); /* 二维数组辨析 */
void test_1d()
{
int a[5] = {0, 1, 2, 3, 4};
int *ap = nullptr;
ap = &a[0]; /* 将数组首个元素a[0]的地址赋给指针ap */
ap = a; /* 与上个语句等价 */
cout << "a[0] = " << a[0] << endl; // 0
*ap = 10; /* 修改第1个元素的值 */
cout << "a[0] = " << a[0] << endl; // 10
cout << "a[1] = " << a[1] << endl; // 1
*(ap+1) = 20; /* 修改下一个(第2个)元素a[1]的值 */
*(a+1) = 20; /* 与上个语句等价 */
cout << "a[1] = " << a[1] << endl; // 20
cout << a << endl; // 输出了数组a代表的地址: &a[0]
}
void test_2d()
{
int a[3][4] = {{1,2,3,4},{5,6,7,8},{9,10,11,12}};
int *p1, *p2, *p3;
for(int i=0; i<3; i++)
{
for(int j=0; j<4; j++)
{
cout << a[i][j] << ' ';
}
cout << endl;
}
p1 = &a[0][0]; /* 将数组第一行第一个元素a[0][0]的地址赋给指针p1 */
cout << '\n' << *p1 << endl; // 1
p2 = a[0]; /* 将数组第一行的起始地址a[0](1维数组名)赋给指针p2 */
cout << *p2 << endl; // 1
cout << *(p2+1) << endl; // 2 p2+1是p2指向的行(首元素地址)的下一个(第2个)元素的地址,故输出a[0][1]
cout << *(a[0]+1) << endl; /* 与上条语句等价,+1可以认为是地址偏移量,C++数组按行优先存储,故向右移动1个单位 */
cout << *(p2+3) << endl; // 4 输出a[0][3]
/* a代表二维数组的地址(指向首个元素),不能直接将a赋值给(int*)类型的指针变量p3,类型不统一。
但*a可以,因为*a代表a[0] */
p3 = *a;
p3 = a[0]; /* 与上条语句等价 */
cout << *p3 << endl; // 1
/* a表示二维数组地址,因此a+1是向下一行进行偏移 */
cout << **(a+1) << endl; // 5 **(a+1)相当于*a[1],即第2行起始地址指向的元素: a[1][0]
cout << *(*a+1) << endl; // 2 注意与上条语句区分: *a+1 = (*a)+1 = a[0]+1
/* 总结: *(a[i]+j) 与 *(*(a+i)+j) 是一样的,都是a[i][j]的值。
Type(a[0]): int [4] (A4_i) Type(a): int [3][4] (A3_A4_i) */
}
int main()
{
test_1d();
test_2d();
return 0;
}
关于二维数组名 a 和 int*
指针变量类型不统一的问题,若是强行赋值:p3 = a;
,则会报错:error: cannot convert 'int [3][4]' to 'int*' in assignment
。若是按照下面的方式定义,则不会出错:
int a[3][4] = {{1,2,3,4},{5,6,7,8},{9,10,11,12}};
int (*p)[4];
p = a; // Type(p) == int (*) [4],赋值合法
思考:为什么指针变量 int * 和数组名的类型 int [4] 作为左值和右值的关系是对应的(赋值语句合法)?指针变量类型 int (*) [4] 和数组名类型 int [3][4] 对应?
解答:因为在C语言中,当你将一个数组赋值给一个指针时,编译器会进行类型转换,因此这种赋值是合法的(只要类型对的上)。在上例中,p指向了数组a的第一个一维数组(第1行),而p被定义为一个指向包含4个整数的数组的指针,因此它可以指向a的一维数组元素,类型兼容,故p = a;
赋值合法。同理,a是一维数组名时也如此分析即可。还可以从这个角度来理解:int (*p)[4]
实际上定义了一个数组指针p
,即指向一个数组的指针,因此赋值语句p = a;
是正确的。
字符数组、字符指针与字符串
#include <iostream>
using namespace std;
void test_str()
{
/* 左边加入了const是因为:如果不加const,等号两边的变量类型不一样。
右值是一个不变常量,左值是一个char*指针变量,不是const类型(那就表示可以修改)。
如果编译通过,那么就说明const(右值,字符串)可以被修改!这显然是不被允许的,因此会弹出警告或报错。 */
const char *str = "Tom Love C++?!"; /* 用字符指针实现字符串的定义 */
char string_array1[] = "Tom hate C++!?"; /* 用字符数组存放字符串,结尾是空字符'\0',也计入数组长度 */
char string_array2[] = {'T','o','m',' ','h','a','t','e',' ','C','+','+','!','?','\0'}; /* 也定义了字符串 */
cout << string_array1 << endl; // Tom hate C++!? 注意:输出的不是地址!与普通数值类型的数组区分开
cout << string_array2 << endl; // Tom hate C++!?
cout << str << endl; // Tom Love C++?!
cout << *str << endl; // T 注意区别
cout << str+1 << endl; // om Love C++?! T没了!
cout << *(str+1) << endl; // o
/* 可以看出,字符指针可以指向字符串中的任意一个字符,很灵活,字符数组则不可以。 */
}
int main()
{
test_str();
return 0;
}
Q:为什么可以直接用字符型数组的名字输出数组值(字符串),而数值型的比如整型却不行呢?
A1:网上的一个答案:在定义流对象,系统会在内存中开辟一段缓冲区,用来暂存输入输出流的数据。在执行 cout 语句时,先把插入的数据顺序存放在输出缓冲区中,直到输出缓冲区满或遇到 cout 语句中的 endl(或‘’,ends,flush)为止,此时将缓冲区中已有的数据一起输出,并清空缓冲区。输出流中的数据在系统默认的设备(一般为显示器)输出。因为 char 型数组中的每一个元素都是一字节,所以每一个字符之间的地址都是 +1 的,是连续的,所以当 cout 输出时读到字符数组中的 '\0' 便停止输出;而 int 数组每个元素占 4 个字节,所以数组中每个元素地址的间隔是4(尽管其实它也是连续的),且没有结束符。
A2:ChatGPT的回答:在C语言中,字符型数组和数值型数组的行为略有不同。当你使用字符型数组的名字时,它会被解释为指向数组第一个元素的指针,而数组的第一个元素通常是一个字符,因此这个指针指向了字符串的首地址。字符串在C语言中以null字符 '\0' 结尾,因此你可以通过打印指向字符串的指针来输出整个字符串,因为 printf 等函数会一直打印直到遇到null字符为止。C++也一样,cout 函数会一直打印直至遇见终止符 '\0'。而数值型数组在使用时通常需要通过数组下标来访问其元素,而不是直接输出整个数组。因为数值型数组的名字会被解释为指向数组第一个元素的指针,但 printf/cout 不会自动解析整个数组,因此需要循环遍历输出。
C++为了继承C语言的指针操作,cout 对 char * 进行了重载。C语言中,字符串的相关操作基本上都是直接打印其值,而非字符串的地址。因此C++中也是如此。
指针数组和数组指针
指针数组:是个数组,数组的元素都是指针,是存储指针的一个数组。数组占多少字节(数组占用的内存是连续的)由数组本身决定(与数组的长度有关)。
数组指针:是个指针,指向某一个数组。在32位系统下永远是4个字节(Bytes)。
/* 定义指针数组 */
int *p1[10];
/* 定义数组指针 */
int (*p2)[4];
通过上例进行分析。对 int *p1[10];
来说,[]
的优先级高于*
,所以p1
先跟[10]
结合,构成一个数组,数组名是p1
。int *
修饰数组,即数组p1
中的每个元素都是int *
类型。也就是说,指针数组中的每一个元素都相当于一个指针变量,它的值都是地址。
而对 int (*p2)[4];
来说,()
优先级最高,*p2
先构成了一个指针的定义,指针变量名是p2
,int
修饰的是数组的内容,即p2
所指数组中的每个元素都是int
类型。也就是说,指针p2
指向了一个包含4个int
类型数据的数组,而且该数组没有名字,是个匿名数组。
下面是一个示例,简单演示了数组指针的用法。
#include <iostream>
using namespace std;
const int MAX = 5;
int main()
{
int a[MAX] = {1,2,3,4,5}; // Type(a) == A5_i (int [5])
int *ap; // Type(ap) == Pi (int*)
int (*p)[MAX]; // Type(p) == PA5_i (int (*) [5])
ap = a; // 合法语句
p = &a; // 合法语句
cout << *ap << endl; // 1
cout << **p << endl; // 1
cout << *(*p+1) << endl; // 2 相当于: *(a+1)
cout << (*p)[4] << endl; // 5
/* p是数组指针,也叫行指针,相当于二维数组名,代表二维数组(首行) */
cout << **(p+1) << endl; // 0 越界了,输出了未定义的内存值,是垃圾数据
return 0;
}
const指针
C++中,const关键字用来指定不可修改的变量,const修饰的变量类似于常量,值初始化后不能被修改(比如重新赋值),可以使编译器帮助用户定义的某些变量不被意外修改。指针也是变量,可以将const用于指针。用法主要有以下三种:
/* 1.指针包含的地址是常量,不能被修改 */
int x = 10;
int* const p = &x; // p被const修饰,p本身(即p中保存的地址)不能被修改
*p = 20; // x == 20,指向的数据可以被修改
int y = 5;
p = &y; // 报错: error: assignment of read-only variable 'p1'
/* 2.指针指向的数据不能被修改 */
int x = 10;
const int* p = &x; // const在 * 前,修饰指针p指向的内容,所以x的值不能被修改
*p = 20; // 报错: error: assignment of read-only location '* p'
int y = 5;
p = &y; // p本身可以被修改,现在p指向的是y了
/* 3.指针本身和它指向的值都是常量,不能被修改 */
int x = 10;
const int* const p = &x;
*p = 20; // 报错: error: assignment of read-only location '*(const int*)p'
int y = 5;
p = &y; // 报错: error: assignment of read-only variable 'p'
有时需要禁止通过引用修改它指向的变量的值,为此也可在声明引用时使用const。例如:
int x = 30;
const int& p = x; // Type(p) == i (int)
p = 15; // 报错: error: assignment of read-only reference 'p'
int& y = p; // 报错: error: binding reference of type 'int&' to 'const int' discards qualifiers
// 翻译: 将类型为"int&"的引用绑定到"const int"将丢弃限定符,类型不匹配(y未被const修饰)
const int& z = p; // 合法语句(类型相同,可以赋值)
C++中引用和指针的区别
在C++中,引用(Reference)和指针(Pointer)都是用来间接访问变量的工具,引用提供了一种更安全和更方便的方式来间接访问变量,相当于给变量重新起了个名字,但是又有指针的作用,而指针提供了更多的灵活性和功能,但也带来了更多的潜在错误和安全风险。
通过指针和引用,你可以直接操作内存,这在很多情况下都非常有用,例如:动态内存分配,函数参数传递,数据结构(如链表和树)等等。
它们之间有一些重要的区别:
1.语法和操作
指针是一个变量,其存储的是另一个变量的地址。指针需要使用取地址运算符
&
来获取变量的地址,并使用解引用运算符 *
来访问存储在该地址上的值。指针可以被赋值为 nullptr
或者另一个变量的地址,可以进行指针算术运算。
引用是一个别名,它本质上是一个指针常量,必须在创建时初始化,并且不能被重新赋值为另一个变量的引用。引用使用
&
符号进行声明,并且在声明时必须绑定到一个已经存在的变量。对引用的操作就像对变量本身的操作一样,不需要使用解引用运算符。
2.空值、安全性和错误
指针可以指向空值(nullptr),表示不指向任何有效的对象。指针可以有空值,也可以指向任何对象,包括无效的对象或未初始化的对象,这可能导致空指针解引用错误(Null Pointer Dereference)或野指针(Dangling Pointer)错误。
引用不能指向空值,必须在初始化时绑定到一个已经存在的对象,因此不存在空指针或者野指针错误。
3.赋值和重定向
指针可以被重新赋值为另一个地址,从而改变其所指向的对象。
引用一旦绑定到一个对象后,就不能再绑定到其他对象。因此,引用不能像指针一样被重新赋值为另一个对象的引用。
4.用途
指针通常用于动态内存分配、数组、函数指针、实现数据结构等需要灵活处理内存和对象的情况。
引用通常用于函数参数传递、函数返回值、提高代码可读性和简洁性的情况,如通过引用避免对象的拷贝。
下面是一段示例代码:
int x = 30; // 假设 x 初始值为 30
int y = 50; // 假设 y 初始值为 50
int& p1 = x; // 引用 p1 绑定到 x
int& p2 = x; // 引用 p2 也绑定到 x
int& p3; // 不允许这样写!引用必须在声明时绑定变量!
// p1、p2 均已绑定 x,因此此处的赋值操作会修改 x 的值
p2 = y; // 将 x 的值修改为 y 的值,即: x == 50
p1 = 100; // 将 x 的值修改为 100
p2 = 200; // 将 x 的值修改为 200 (上面的赋值操作都被覆盖了)
cout << "x: " << x << "\t" << "p1: " << p1 << endl; // 输出 x 和 p1 的值
cout << "y: " << y << "\t" << "p2: " << p2 << endl; // 输出 y 和 p2 的值
// 输出
x: 200 p1: 200
y: 50 p2: 200
传值、传指针、传引用调用
#include <iostream>
using namespace std;
void swap(int a, int b);
void swap(int *a, int *b);
void swap_ref(int& a, int& b);
int main() {
int x = 10;
int y = 50;
int& z1 = x;
int &z2 = y;
cout << "Before: x = " << x << " y = " << y << endl;
swap(x, y); // 交换失败
swap(&x, &y); // 交换成功
swap_ref(z1, z2); // 交换成功
cout << "After: x = " << x << " y = " << y << endl;
}
void swap(int a, int b) {
int temp;
temp = a;
a = b;
b = temp;
}
void swap(int *a, int *b) {
int temp;
temp = *a;
*a = *b;
*b = temp;
}
void swap_ref(int& a, int& b) {
int temp;
temp = a;
a = b;
b = temp;
}
悬挂指针和野指针
悬挂指针(Dangling Pointer),是指向已经释放或者已经超出作用域的内存的指针。
int *pt = new int(5);
delete pt; // 此时这块内存已被操作系统回收,可能重新分配给其他程序了
*pt = 10; // Undefined behavior
野指针(Wild
Pointer),是未初始化的指针,如:int *pt;
如何避免悬挂指针和野指针
悬挂指针的避免很容易理解:指针变量经过 delete (或free)
操作以后,其指向的内存会被释放,继而被系统回收。只需要对该指针赋个空值,让它别再继续指向这块已经被释放掉的内存空间即可。如:pt = NULL;
避免野指针的常见建议如下:
(1)初始化空指针 nullptr
在 C++ 中,未初始化的指针变量会被默认初始化为一个未定义的值,即它们可能指向任意位置。因此,在使用指针变量之前,应该始终确保对其进行初始化,以避免未定义的行为。
int *p1 = nullptr;
这是 C++11
中引入的新方式,声明指向整数的指针 p1,并使用 nullptr 初始化。nullptr 是
C++11 标准中的一个关键字,专门用于表示空指针。它的类型是
std::nullptr_t,可以转换为任何其他指针类型,并且优于
NULL,因为它消除了整数和空指针之间的歧义。
int *p2 = NULL;
这条语句声明了指向整数的指针
p2,并将其初始化为 NULL。NULL 是 C++ 中的一个宏,代表空指针,在 C++
中它通常被定义为 0。尽管使用 NULL 是合法的,但它是基于 C
的遗留实践,C++11 后推荐使用 nullptr。
int *p3;
这条语句仅仅声明了指向整数的指针
p3,没有初始化。在这种情况下,p3
将包含一个垃圾值(未定义的内存地址)。如果你没有正确初始化而试图使用这个指针,会导致未定义的行为,可能会带来安全风险和难以调试的bug。
(2)使用智能指针
C++ 中引入了一些智能指针,如 unique_ptr、shared_ptr 等,在它们的生命周期结束时会自动释放所管理的资源,可以减少野指针和悬挂指针的风险。
例如:std::unique_ptr<int> ptr(new int(5));
(3)避免使用裸指针
在可能的情况下,尽量使用引用或智能指针。
### 智能指针
C++提供了四种类型的智能指针:unique_ptr、shared_ptr、weak_ptr、auto_ptr。它们都位于 <memory> 头文件中,并在 std 命名空间下。
更详细的内容可以看这篇文章:C++智能指针详解
unique_ptr
这是C++11中引入的一个智能指针,unique_ptr 对象拥有其所指向的对象(所有权语义),但所有权不能被复制,只能通过 std::move 转移。这意味着在任何时刻,最多只有一个 unique_ptr 指向某个给定的对象,unique_ptr 被销毁时对象将被删除。这使得 unique_ptr 成为管理堆上创建的单一对象或者原始数组的理想选择。
shared_ptr
这是C++11中引入的另一个智能指针,它实现了共享所有权语义,即多个 shared_ptr 对象可以指向同一个对象,该对象在最后一个 shared_ptr 被销毁时删除。shared_ptr 使用了引用计数来跟踪有多少 shared_ptr 指向当前这个对象。
weak_ptr
这是C++11中引入的第三种智能指针,它是为了配合 shared_ptr 使用的,对一个对象产生弱引用。一个 weak_ptr 对象可以指向一个由 shared_ptr 对象指向(拥有)的对象,但它不参与所有权的管理。也就是说,weak_ptr 对象的存在不会阻止其指向的对象被删除。weak_ptr 常被用来解决 shared_ptr 中可能出现的循环引用问题。
auto_ptr
这是C98标准库中的一个智能指针,在C++11中被弃用,已在C++17中被移除。auto_ptr 对象也拥有其所指向的对象,但所有权可以被转移,会导致一些违反直觉的行为。不建议使用 auto_ptr,它已经被 unique_ptr 所替代。用户应当优先使用 unique_ptr 和 shared_ptr 进行强引用,或者 weak_ptr 进行弱引用。
C++中的类和对象
类的定义 & 对象的创建
定义一个类,本质上就是定义一个数据类型的蓝图。
class Goods {
public: // 访问限定符
char name[30]; // 成员变量
int amount;
int price(); // 成员函数
};
int Goods::price() { // 类外定义
return amount*10;
}
声明对象有两种形式:普通对象和指针。
Goods good; // 声明对象
Goods *pGood = &good; // 指针
good.amount = 10; // 访问
pGood->amount = 10;
public
表示公有成员,在程序中类外可以访问;private
表示私有成员,在类外不能访问,只能在本类和友元中访问;protected
表示保护成员,只能被本类或派生类的成员函数访问。默认:private
。
友元函数是可以直接访问类的私有成员的非成员函数,有利于类间数据的共享,但是破坏了类的封装性。友元函数内部没有 this 指针!友元不是类的成员,只有成员函数才有 this 指针。
构造函数
构造函数(Constructor)是对数据成员进行初始化的特殊函数,在对象被创建时由系统自动调用。初始化就相当于(自动)调用构造函数。
构造函数有几个特点:
- 函数名与类名一样
- 不需用户调用,也不能被用户调用
- 可以在类中定义,也可以在类外定义
- 无函数返回值类型说明,但有返回值,返回构造函数所对应的被创建的对象
- 若类说明中没有给出构造函数,则C++默认给出一个缺省构造函数,不过函数体为空
构造函数可以带参数(实参在创建对象时给出),有重载机制,它的一项重要功能就是对成员变量进行初始化(第一次赋值)。为达此目的,可以在构造函数的函数体中对成员变量逐一赋值,也可以采用参数初始化表。注意:用参数初始化表进行参数初始化的顺序与表列出的顺序无关,只与成员变量在类中声明的顺序有关。
下面是构造函数的一个实例:
#include <iostream>
using namespace std;
class Date {
private:
int a; // 私有成员变量
int b;
public:
Date(int); // 构造函数(必须是public型)
void show(); // 公有成员函数
};
// 通过参数初始化表,按成员变量在类中声明的顺序赋值
// 先把b赋给a,此时b中是垃圾数据(比如0),然后把给的参数i赋给b
[1] Date::Date(int i) :b(i), a(b) { }
// 先依次执行: a = b、b = i,再执行函数体中的语句: a = b
[2] Date::Date(int i) :b(i), a(b) {
a = b;
}
void Date::show() {
cout << "a = " << a << '\t' << "b = " << b << endl;
}
int main() {
Date d(55); // 实例化对象
Date *p = new Date(336); // 动态申请内存
Date *p2 = &d;
d.show(); // [1] a = 0 b = 55 [2] a = 55 b = 55
p->show(); // [1] a = 0 b = 336 [2] a = 336 b = 336
p2->show(); // 结果与d.show()一样
delete p; // new完一定记得delete!
}
函数重载、复制构造函数与析构函数
构造函数可以重载,要求:参数个数或参数类型不相同。因此一个类中可以有多个构造函数,但对于每一个对象来说,建立对象时只会执行其中一个构造函数。一个类中只能有一个默认构造函数(即无参的构造函数)。
复制构造函数(Copy
Constructor)是一个特殊的构造函数,就是用一个已有的对象,以复制的方式快速产生多个完全相同的对象。为了确保相同,在对象的引用形式上一般要加const
声明,使参数值不能改变,以防止调用此函数时不慎将对象值修改了。
析构函数(Destructor)也是一种特殊的成员函数,没有返回值。当对象生命周期结束时,系统自动执行析构函数,其会进行清理工作,如销毁对象、释放分配的内存、关闭打开的文件等。在类名(构造函数名)前加上"~"就构成了析构函数名。
析构函数执行的顺序类似于栈,“先进后出”,最先被调用的构造函数,其对应的对象的析构函数最后被调用。当然,并不是任何情况下都按这一原则处理,这与作用域有关。比如静态局部对象,在函数执行完以后并不会释放,直到程序结束以后要释放它时,才会调用它的析构函数。
C++中,每一个成员函数中都包含一个特殊的指针,叫 this指针。它是指向本类对象的指针,它的值是当前被调用的成员函数所在的对象的起始地址。
#include <iostream>
using namespace std;
class Box {
private:
int length;
int width;
int height;
public:
// 默认参数的构造函数。也可以在定义的时候填写默认参数,在这里只需要声明类型即可
Box(int len=10, int w=10, int h=10);
Box(const Box& box); // 复制构造函数 + 重载
int volume();
~Box(); // 析构函数
};
Box::Box(int len, int w, int h) {
// 下面三条语句等价。C++中,this是个指针,指向调用该成员函数的对象
length = len;
this->width = w;
(*this).height = h;
cout << "调用了构造函数" << endl;
}
Box::Box(const Box& box) {
length = box.length;
width = box.width;
height = box.height;
cout << "调用了复制构造函数" << endl;
}
int Box::volume() {
cout << "length = " << length << endl;
cout << "width = " << width << endl;
cout << "height = " << height << endl;
cout << "volume: ";
return(length*width*height);
}
Box::~Box() {
// 输出一下相关信息
cout << "调用了析构函数,清理工作已完成!" << '\n' << this->volume() << endl;
}
int main() {
Box box1(1,1); // 自动局部对象box1,调用构造函数,第三个析构
cout << box1.volume() << endl;
Box box2(9,8,10); // 自动局部对象box2,调用构造函数,第二个析构
cout << box2.volume() << endl;
Box box3 = box1; // 自动局部对象box3,调用复制构造函数,最先被析构
cout << box3.volume() << endl;
static Box box4; // 静态局部对象box4,调用构造函数,最后被析构
cout << box4.volume() << endl;
return 0;
}
静态数据成员
静态数据成员,为类的所有对象共享,内存中只占用一份空间。在创建第一个对象时,所有的静态数据都会被初始化为0,因此不能将静态成员的初始化放在类中定义,否则会冲突。
class Box {
public:
int volume();
private:
static int length;
int width;
int height;
};
如果希望用Box类实例化的每个对象的length属性值都一样,那么就可以用static
把它定义为静态数据成员。如果只声明了类而未定义对象,则类的一般数据成员是不占内存空间的,只有在定义对象时,才为对象的数据成员分配空间。但是静态数据成员不属于某一个对象,在为对象所分配的空间中不包括静态数据成员所占的空间。静态数据成员在所有对象之外单独开辟空间。只要在类中定义了静态数据成员,即使不定义对象,也为静态数据成员分配空间,它可以被引用。在一个类中可以有一个或多个静态数据成员,所有的对象共享这些静态数据成员,都可以引用它。
静态数据成员既可以通过对象名引用,也可以通过类名引用。
对于静态变量,如果在一个函数中定义了静态变量,在函数结束时该静态变量并不释放,仍然存在并保留其值。静态数据成员也类似,它不随对象的建立而分配空间,也不随对象的撒销而释放(一般数据成员是在对象建立时分配空间,在对象撒销时释放)。静态数据成员是在程序编译时被分配空间的,到程序结束时才释放空间。
静态数据成员可以初始化,但只能在类体外进行初始化。注意:不能用参数初始化静态数据成员,如参数初始化表就不行。
class Box {
public:
int product();
private:
static int len;
int width;
};
int Box::len = 5; // 初始化静态成员变量
有了静态变量,各对象之间的数据有了沟通的渠道,实现了数据共享。
静态成员函数,可以用类名加 "::" 进行访问,即便在类对象不存在的情况下也能被调用。静态成员函数只能访问静态成员数据、其他静态成员函数和类外部的其他函数。静态成员函数没有 this 指针,只能访问静态成员,不能访问实例成员(普通成员)。
作用域限定符(Scope Resolution Operator)"::" 还可以用来访问全局变量,但是不能在某个语句块内部访问在语句块外声明的局部变量。关于静态变量和全局变量、普通变量的区别,可以查看后文《C/C++中的static关键字》,也可以点击这个链接跳转访问:菜鸟教程:C/C++中的static。看下面这个例子:
int x = 11; // 全局变量
int main() {
int x = 22; // 局部变量
cout << ::x << endl; // 访问的是全局变量:x == 11
cout << x << endl; // 访问的是局部变量:x == 22
::x = 33; // 修改全局变量的值
}
常量成员
用const修饰的成员变量、成员函数、对象叫常量数据成员。由于const成员变量无法被修改,初始化常量数据成员的唯一方法就是用参数初始化表。
const成员函数可以使用类中所有成员变量,但是不能修改它们的值。const成员函数也叫常量成员函数。
相应地,创建对象时,也要用const进行修饰,常量对象可以且只能调用类的const成员函数(const类型相互匹配)(除了系统自动调用的隐式的构造函数和析构函数)。
class Time {
public:
Time(int h, int m, int s);
int getHour() const; // 声明常量成员函数
private:
const int hour;
const int minute;
const int second;
};
// 必须使用参数初始化表
Time::Time(int h, int m, int s) :hour(h), minute(m), second(s) { }
int Time::getHour() const // 定义函数时也要加上const
{
return hour;
}
int main() {
Time const time(10, 13, 56);
}
静态与动态内存
通常定义变量(或对象)后,编译器在编译时都可以根据该变量(或对象)的类型知道所需内存空间的大小,然后系统可以在适当的时候为它们分配确定的存储空间。这种内存分配称为静态存储分配。
有些操作对象只在程序运行时才能确定,这样编译时就无法为它们预定存储空间,只能在程序运行时,系统根据运行时的要求进行内存分配,这种方法称为动态存储分配。所有动态存储分配都在堆区中进行。C++程序中的内存分为两个部分:
(1)栈:在函数内部声明的所有变量都将占用栈内存。
(2)堆:这是程序中未使用的内存,在程序运行时可用于动态分配内存。
很多时候,用户无法提前预知需要多少内存来存储某个已定义的变量中的特定信息,所需内存的大小需要在运行时才能确定。因此在C++中,可以使用特殊的运算符为给定类型的变量在运行时分配堆内的内存,这会返回所分配的空间地址。这种运算符是new运算符。如果不再需要动态分配的内存空间,可以使用delete运算符,删除之前由new运算符分配的内存。
double *p = nullptr; // 初始化 null 指针
p = new double; // 为变量请求内存
/* 改进如下 */
if(!(p = new double))
{
cout << "动态内存分配失败!" << endl;
exit(1);
}
malloc函数在C语言中较常使用,建议C++中使用new运算。因为new不只分配了内存,还创建了对象。在任何时候,当用户觉得已经动态分配的变量不再需要使用时,可以使用delete操作符将其释放。
对象的动态内存分配:
class Base
{
int x;
public:
Base(int x) {
this->x = x;
}
int fun() {
return x;
}
};
int main() {
Base *p = new Base(100);
if(!p) {
cout << "动态内存分配失败!" << endl;
return 1;
}
cout << "x = " << p->fun() << endl;
delete p;
return 0;
}
C/C++中的static关键字
区别
在C和C++中,static关键字有三个主要的用途,但其在C++中的用法更加丰富:
在函数内部:在C和C++中,static关键字可用于函数内部变量。此时,此变量的生命周期将贯穿整个程序,即使函数执行结束,这个变量也不会被销毁。每次调用这个函数时,它都不会重新初始化。这可以用于实现一些需要保持状态的函数。
在函数外部或类外部:在C和C++中,static关键字可以用于全局变量或函数。此时,此变量或函数的作用域被限制在定义它的文件内,无法在其他文件中访问。这可以防止命名冲突或不必要的访问。
在类内部:只有C++支持此用法。在C++中,static关键字可以用于类的成员变量或成员函数。对于静态成员变量,无论创建多少个类的实例,都只有一份静态成员变量的副本。静态成员函数则可以直接通过类名调用,而无需创建类的实例。
class MyClass {
public:
static int count; // 静态成员变量,所有实例共享一份
MyClass() {
count++; // 每次创建实例,计数加1
}
static int getCount() { // 静态成员函数,可通过类名直接调用
return count;
}
};
int MyClass::count = 0; // 静态成员变量的初始化
int main() {
MyClass a;
MyClass b;
MyClass c;
std::cout << MyClass::getCount(); // 输出3,因为创建了3个实例
}
作用
在C++中,static关键字有多个用途,它的作用主要取决于它在哪里被使用:
在函数内部:如果static被用于函数内部的变量,那么它会改变该变量的生命周期,使其在程序的整个运行期间都存在,而不是在每次函数调用结束时被销毁。这意味着,这个变量的值在函数调用之间是保持的。
在函数外部:如果static被用于函数外部的全局变量或函数,那么它会将这个变量或函数的链接范围限制在它被定义的文件内。换句话说,这个变量或函数不能在其他文件中被直接访问。这可以帮助减少命名冲突,而且能提供一种控制变量和函数可见性的方式。
在类中:如果static被用于类的成员变量,那么该变量将会成为这个类的所有实例共享的变量,也就是说,类的每个实例都能访问到这个同样的变量。如果static被用于类的成员函数,那么这个函数可以直接通过类来调用,而不需要创建类的实例。
C++核心之继承与多态
封装:encapsulation(package)
继承:inheritance
多态:polymorphism
继承用法:子类初始化
子类/派生类/扩展类(Sub-class/derived class)可以继承父类/基类/超类(Super-class/base class)。
#include <iostream>
#include <string>
using namespace std;
class People {
public:
People(string n, int a, float h) { // 基类构造函数
name = n;
age = a;
height = h;
}
protected:
string name;
int age;
float height;
};
class Student :public People { // 公有继承
public:
// 派生类的构造函数写法(初始化方法)
Student(string n, int a, float h, string s, string id) :People(n, a, h) {
sex = s;
identity = id;
}
void show() {
cout << "Show student" << endl;
}
private:
string sex;
string identity;
};
int main() {
Student stu("张三", 26, 188, "Gentleman", "XDer");
stu.show();
return 0;
}
派生类的构造方法,仍然按照类的构造方法的语法写,但是形参列表需要将涉及到的所有参数都列出来,即总参数列表,按照先基类后派生类的次序,根据声明顺序依次列出。后面需要用参数初始化表的语法格式,:People(n,a,h)
表示父类构造函数的信息,即派生类会调用父类构造函数及其参数,将相应的实参传递过去。然后再执行派生类函数体中的语句,为自己的成员变量初始化。也可以全部采用参数初始化表进行简化:
Student(string n, int a, float h, string s, string id) :People(n,a,h), sex(s), identity(id) { }
继承的三种方式
派生类继承有三种方式:公有继承 public、保护继承 protected、私有继承 private。
三种继承的特点见下表:
基类中的成员 | 在公有派生类中的访问属性 | 在保护派生类中的访问属性 | 在私有派生类中的访问属性 |
---|---|---|---|
公有成员 | 公有 | 保护 | 私有 |
保护成员 | 保护 | 保护 | 私有 |
私有成员 | 不可访问 | 不可访问 | 不可访问 |
如果基类中需要定义私有的数据成员,要将其定义为 peotected 类型,这样派生类就可以继承并使用了。
使用继承时需要注意以下内容:
(1)父类的构造函数和析构函数不会被继承,派生类需要重写。
(2)派生类的构造函数调用顺序:先调用各个直接基类的构造函数,之后再调用成员对象的构造函数,最后进行新增成员的初始化。
(3)对于多继承有多个基类,按照被继承时的声明顺序从左到右依次调用,与初始化表的顺序无关。对成员对象的初始化也一样,按照声明顺序而非初始化表的顺序。
final and override
final 和 override 是 C++11 引入的两个关键字,主要用于类的继承和虚函数的覆盖(即重写)。
final:如果一个类被声明为
final,那么它不能被继承。例如,class Base final { ... };
,此时任何试图继承
Base 的类都会导致编译错误。此外,如果一个虚函数被声明为
final,那么它不能在派生类中被覆盖。例如,virtual void fun() final;
,此时任何派生类试图覆盖
fun() 函数都会导致编译错误。
override:如果一个虚函数被声明为
override,那么编译器会检查这个函数是否真的覆盖了基类中的一个虚函数。如果没有,编译器会报错。这个关键字可以帮助我们避免因为拼写错误或者函数签名错误而导致的编译报错。例如,void fun() override;
,如果基类中没有一个函数的签名和
fun() 完全匹配,那么编译器就会报错。
举个例子:假设我们有一个基类 Animal 和一个派生类 Dog,Animal 有一个虚函数 make_sound(),Dog 需要覆盖这个函数。如果我们在 Dog 的 make_sound() 函数声明中加上了 override 关键字,那么如果我们不小心将函数名拼写成了 maek_sound(),编译器就会因为找不到对应的基类函数而报错,帮助我们及时发现错误。
多重继承
下例给出了类 D 分别以公有方式继承了基类 A 和 B 的一种实现。
class A { // 基类 A
public:
A(int a, int b) {
A_a = a;
A_b = b;
}
protected:
int A_a;
int A_b;
};
class B { // 基类 B
public:
B(int c, int d) :B_c(c), B_d(d) { }
protected:
int B_c, B_d;
};
class D :public A, public B { // 派生类 D
public:
D(int a, int b, int c, int d, int e);
private:
int D_e;
};
// 子类构造函数
D::D(int a, int b, int c, int d, int e) : A(a,b), B(c,d), D_e(e) { }
注意:在派生类构造函数中,基类构造函数的调用顺序与声明/定义基类的顺序相同,与派生类构造函数初始化表中基类构造函数出现的顺序无关。
容易想到,若继承的多个基类有同名的数据成员,则访问的时候会出现“二义性”(ambiguous)的问题,也就是不知道访问的是哪一个父类中的数据成员。可以用作用域限定符/域解析运算符
:: 进行说明。如:d.A::x = 3;
表示派生类 D 的对象 d
访问的是其所继承的基类 A 中的那个成员变量
x,为它重新赋值为3。又如:d.B::show();
表示 d 访问的是基类
B 中的成员函数 show()。
类的引用
引用相当于别名,在内存中共享同一段空间。
若一个指针先被定义为指向基类对象,那么后续再对其重新赋值,让其指向派生类对象,也只能访问派生类中的基类成员。关于这一点,可以查阅后文:《多态的动态实现:虚函数》。
基类和派生类可以相互转换,但是会存在“切片”问题,即把派生类类型的变量赋值给基类类型时,系统自动进行隐式转换,但是只会将派生类中基类的部分赋过去,派生类自己新增的信息将丢失。这个解决办法就是后文中的多态机制。
看下面这个代码片段:
class A { };
class B :public A { };
int main() {
A a; // 创建基类的对象
B b; // 创建派生类的对象
A &r = a; // 创建一个引用: r
return 0;
}
引用 r 是 a 的别名,两者共享同一段内存单元。若对 r
重新赋值:r = b;
,此时 r 并非 b 的别名!所以 r 不与 b
共享同一段存储单元。r 只是 b 中基类部分的别名,r 与 b
中基类部分共享同一段存储单元,r 与 b 具有相同的起始地址。
认识多态性
顾名思义,一个事物的多种形态称之为多态。在C++程序设计中,多态性就相当于具有不同功能的成员函数可以共用同一个函数名,这样就可以用一个函数名调用具有不同功能的成员函数,用于类的继承中。
在面向对象方法中一般是这样表述多态性的:向不同的对象发送同一个消息,不同的对象在接收时会产生不同的行为(即各自的方法)。也就是说,每个对象可以用自己的方式去响应共同的消息。所谓消息,就是调用(基类)函数,不同的行为就是指不同的实现,即执行不同的函数。函数重载就是一种多态性。
从系统实现的角度看,多态性分为两类:静态多态性和动态多态性。
函数重载、运算符重载就属于静态多态性,在程序编译时系统就能决定调用的是哪个函数,因此静态多态性又称编译时的多态性。静态多态性是通过函数的重载实现的。
动态多态性是在程序运行过程中才动态地确定操作所针对的对象,它又称运行时的多态性。动态多态性是通过虚函数实现的。
多态的动态实现:虚函数
C++的多态性用一句话概括就是:在基类的函数前加上 virtual 关键字,在派生类中重写该函数,在主函数中用指针进行动态绑定实现多态(疑问:为什么一定要使用指针?)。运行时将会根据对象的实际类型来调用相应的函数。如果对象类型是派生类,就调用派生类的函数;如果对象类型是基类,就调用基类的函数。
看一个例子:
#include <iostream>
using namespace std;
class Father {
public:
void show() {
cout << "This is a Father!" << endl;
}
virtual void display() {
cout << "Polymorphism: Father!" << endl;
}
};
class Son :public Father {
public:
void show() {
cout << "This is a son!" << endl;
}
virtual void display() { // 这里的 virtual 可以省略
cout << "Polymorphism: son!" << endl;
}
};
int main() {
Father f;
Son son;
f.show(); // 输出: This is a Father!
f.display(); // 输出: Polymorphism: Father!
Father *p = &son;
p->show(); // 输出: This is a Father!
p->display(); // 输出: Polymorphism: son!
return 0;
}
C++编译器在编译的时候,要确定每个对象调用的函数(非虚函数)的地址,这称为早期绑定。将派生类对象的地址赋给父类型的指针变量时:Father *p = &son;
,C++编译器进行了类型转换,此时编译器认为变量
p 保存的就是基类 Father 的对象的地址,若用 p 指针调用子类型对象 son
中重写的父类的函数,会失败,其实调用的仍然是父类自己的函数。
从内存角度来解释:用户在构造子类对象 son 的时候,首先要调用 Father 类的构造函数去构造基类的对象,然后才调用子类的构造函数完成对象 son 自身(增加)的部分。这时在内存中,son 所占的空间就是两部分拼接的结果。当用户将 son 对象转换为 Father 类型时(上述赋值语句),该对象就被认为是原对象整个内存的上半部分,即 Father 对象所占的内存,因此用 p 调用 son 中继承而来并重写的父类函数时,实际上调用的仍是父类本身的函数。
归根结底,是因为编译器在编译的时候,就已经确定了对象调用的函数的地址,要解决这个问题就要使用晚期绑定。当编译器使用晚期绑定时,就会在运行时再去确定对象的类型以及正确的调用函数,而要让编译器采用晚期绑定,就要在基类中声明函数时使用 virtual 关键字,这样的函数称之为虚函数。注意:一旦某个函数在基类中声明为 virtual,那么在所有的派生类中该函数都是 virtual 类型,而不需要再显式地声明为 virtual。虚函数肯定是类的成员函数。
在派生类中重新定义基类中定义的虚函数时,会告诉编译器不要静态链接到该函数。用户想要的是在程序中任意点都可以根据所调用的对象类型来选择调用的函数是哪一个(对象相应的派生类中的还是基类中的),这种操作被称为:动态联编,或动态绑定,或晚期绑定。因此,基类必须将它的两种成员函数区分开:希望被派生类覆盖的函数,或是只继承不改动的函数。对于前者,就可以利用多态机制了。
虚函数表
当用户在派生类中覆盖某个函数时,可以在函数前加 virtual 关键字。然而这不是必须的,因为一旦某个函数被声明成虚函数,则所有派生类中它都是虚函数。任何构造函数之外的非静态函数都可以是虚函数。派生类经常覆盖它继承的虚函数,如果派生类没有覆盖其基类中某个虚函数,则该虚函数的行为类似于其他的普通成员,派生类会直接继承其在基类中的版本。编译器在编译的时候,一旦发现某个类中有虚函数,就会为所有包含虚函数的类创建一个虚表,即 vtable,该表是一个一维数组,其中存放每个虚函数的地址。编译器还为虚表提供了一个虚表指针(vptr),这个指针指向了对象所属类的虚表。在程序初始化的时候,编译器会根据对象的类型初始化 vptr,让该指针正确指向所属类的虚表,从而在调用虚函数的时候,能够找到正确的那个函数。
在构造函数中进行虚表的创建和虚表指针的初始化。在构造子类对象时,要先调用父类的构造函数,此时编译器只“看到了”父类,并不知道后面是否还有继承者,它初始化父类对象的虚表指针,该虚表指针指向父类的虚表。当执行子类的构造函数时,子类对象的虚表指针被初始化,指向自身的虚表。虚函数表有以下特点:
- 每一个类都有虚表。
- 虚表可以继承,如果子类没有重写虚函数,那么子类虚表中仍然会有该函数的地址,只不过这个地址指向的是基类的虚函数实现,如果基类有3个虚函数,那么基类的虚表中就有三项(虚函数地址)。派生类也会创建虚表,且至少有三项,如果重写了相应的虚函数,那么虚表中的地址就会改变,指向自身的虚函数实现。如果派生类有自己的虚函数,那么虚表中就会添加该地址。
- 派生类的虚表中虚地址的排列顺序和基类的虚表中虚函数地址排列顺序相同。
存在虚函数的类都有一个一维的虚函数表(虚表),类的对象有一个指向虚表开始的虚指针。虚表是和类对应的,虚表指针是和对象对应的。
虚函数的使用方式
直接看这个示例:
#include <iostream>
#include <string>
using namespace std;
class People { /* 声明基类People */
public:
People(int, string, int); /* 声明构造函数 */
virtual void show(); /* 声明虚成员函数 */
protected: /* 受保护成员,派生类可以访问 */
int id;
string name;
int age;
};
People::People(int id, string name, int age) {
this->id = id;
this->name = name;
this->age = age;
}
void People::show() {
cout << "id: " << id << "\tname: " << name << "\tage: " << age << endl;
}
class Student :public People { /* 声明公有派生类Student */
public:
Student(int, string, int, float); /* 声明构造函数 */
virtual void show(); /* 声明成员函数,virtual 可以省略 */
private:
float score;
};
Student::Student(int id, string name, int age, float score) :People(id,name,age), score(score) { }
void Student::show() {
cout << "id: " << id << "\tname: " << name << "\tage: " << age << "\tscore: " << score << endl;
}
int main() {
People peo(2001, "老鸨", 23); /* 定义People类对象peo */
Student stu(2006, "马猴", 18, 85); /* 定义Student类对象stu */
People *pt = &peo; /* 定义指向基类对象的指针变量 */
pt->show(); // 输出: id: 2001 name: 老鸨 age: 23
pt = &stu; /* 父类型指针变量指向子类型对象 */
pt->show(); // 输出: id: 2006 name: 马猴 age: 18 score: 85
return 0;
}
在过去,派生类的输出函数与基类的输出函数不同名(如show和show_1),但如果派生的层次多,就要起许多不同的函数名,很不方便。如果采用同名函数,又会发生同名覆盖问题。利用虚函数就很好地解决了这个问题。当把基类的某个成员函数声明为虚函数后,允许在其派生类中对该函数重新定义,赋予它新的功能,并且可以通过指向基类的指针指向同一类族中不同类的对象,从而调用其中的同名函数。由虚函数实现的动态多态性就是:同一类族中不同类的对象,对同一函数调用作出不同的响应。
虚函数是在基类中用 virtual 声明定义,然后在子类中重写这个函数后,基类的指针指向子类对象,从而可以调用子类的这个函数。虚函数的使用方法是:
(1)在基类用 vitual 声明成员函数为虚函数。这样就可以在派生类中重新定义此函数,为它赋予新的功能,并能方便地被调用。在类外定义虚函数时,不必再加 virtual。
(2)在派生类中重新定义此函数,要求函数名、函数类型、函数参数个数和类型全部与基类的虚函数相同,并根据派生类的需要重新定义函数体。
(3)定义一个指向基类对象的指针变量,并使它指向同一类族中需要调用该函数的对象。
(4)通过该指针变量调用此虚函数,此时调用的就是指针变量指向的对象的同名函数。
C++规定,当一个成员函数被声明虚函数后,其派生类中的同名函数都自动成为虚函数。因此在派生类重新声明该虚函数时,可以加 virtual,也可以不加,但习惯上一般在每一层声明该函数时都加 virtual,使程序更加清晰。如果在派生类中没有对基类的虚函数重新定义,则派生类简单地继承其直接基类的虚函数。
动态关联与静态关联
编译系统要根据已有的信息,对同名函数的调用做出判断。例如函数的重载,系统是根据参数的个数和类型的不同去找与之匹配的函数的。对于调用同一类族中的虚函数,应当在调用时用一定的方式告诉编译系统,你要调用的是哪个类对象中的函数。例如可以直接提供对象名,如 student.display() 或 teacher.display(),这样编译系统在对程序进行编译时,立即能确定调用的是哪个类对象中的函数。
确定调用的具体对象的过程称为关联(binding)。binding 原意是捆绑或连接,即把两样东西连接在一起。在这里是指把一个函数名与一个类对象捆绑在一起,建立关联。一般地说,关联是指把一个标识符和一个存储地址联系起来。
多态性是指为一个函数名关联多种含义的能力,即同一种调用方式可以映像到不同的函数。这种把函数的调用与适当的函数体对应的活动又称为绑定。根据绑定所进行阶段的不同,可分为早期绑定(early binding)、晚期绑定(late binding)。早期绑定发生在程序的编译阶段,称为静态关联(static binding),晚期绑定发生在程序的运行阶段,称为动态关联(dynamic binding)。
早期绑定,也称为编译期多态,指绑定是发生在编译阶段,例如:函数的重载。晚期绑定,也称为动态联编、动态绑定,指在运行时实现多态。动态绑定时,编译器由于只做静态的语法检查,系统不能确定调用对象是谁,在运行阶段,指针变量调用了某个对象的函数,系统才知道是哪一个对象,才会触发多态机制。由于动态关联是在编译以后的运行阶段进行的,因此也成为滞后关联(late binding)。
纯虚函数
纯虚函数也是用 virtual 进行修饰,但是可以不用定义,只需声明即可。纯虚函数是在声明虚函数时被“初始化”为0的函数,基类中并不使用这个函数,所以返回值没有意义。纯虚函数没有函数体,只需声明,所以语句后面有分号。
纯虚函数只有名字而不具备函数的功能,不能被调用。它只是通知编译系统:这里声明的是一个纯虚函数,留待派生类中再定义。在派生类中对此函数进行重写后,它才具有功能,方可被调用。
包含纯虚成员函数的类叫抽象基类,无法实例化,不能由该基类创建对象,所以很“抽象”。但是可以产生基类指针,可以被派生类继承。但需要注意的是,派生类继承了抽象基类后,纯虚函数也会被继承而来,若派生类不对继承来的所有纯虚成员函数进行重写,那么该派生类同样会变成一个抽象基类。
抽象基类可以用于实现公共接口,在抽象基类中声明的纯虚成员函数,派生类如果想要能创建对象,则必须全部重新定义这些纯虚函数。注意:虚函数所在的基类是可以创建对象的!纯虚函数(抽象基类)主要用在这种情形:基类中不需要这个函数(功能),且不需要产生对象,但是子类型需要,所以在基类中为子类“占个位”,子类继承基类后只需要重写该函数(纯虚函数)就可以使用了。
纯虚函数语法:
class Base {
public:
// 纯虚函数的声明,此时Base类是抽象基类
virtual void show() = 0;
};
后面的 =0 并非表示函数返回值为0,也不表示赋值,它只起形式上的作用,即告诉编译系统:这是纯虚函数。
C++容器
容器,是一个特殊的对象,该对象保存其他对象,即持有其他对象或指向其他对象的指针,还包含了一些处理其他对象的方法。实质上,容器就是一组相同类型对象(数据)的集合,用于对数据的存储和处理,比数组更加强大。
容器类还是一种对特定代码重用问题的良好解决方案。此外,容器可以自行扩展,可以动态申请内存、释放内存,且使用最优算法执行命令。
容器的分类
容器有两大类别:顺序容器(Sequence container)、关联容器(Associative container)。
顺序容器
各个元素之间有顺序关系的线性表,每个元素都有固定的位置,呈现某种逻辑顺序,通过在容器中的位置来保存和访问元素。更改位置只能通过插入、删除元素实现。
特点:插入和查询速度快、适合数据的修改操作频繁的场景。
常见的顺序容器有三种:vector、list、deque。
vector
向量 vector,是一个动态的顺序容器,这是用的最广泛的容器。vector 是具有连续内存地址的数据结构,类似数组,支持下标(索引)进行随机、快速地访问,但是存储空间是动态可扩展的。而且 vector 可以相对高效的在尾部插入、删除元素。只允许一端插入、删除元素,内部插入、删除效率很低。
vector 的查询性能最好,而且末端增加数据的性能也很好。
须包含头文件:#include <vector>
,在命名空间 std
中。
示例:
/* 初始化, 范围区间: 左闭右开 */
int arr[] = {1,2,3,4,5};
vector<int> a(8, 1); // 8 个值全为 1 的 vector, a.size(): 8
vector<int> b(a.begin(), a.end()); // 把 a 从头到尾全赋给 b, b.size(): 8
vector<int> c(a.begin(), a.begin()+3); // 把 a 前 3 个元素赋给 c, c.size(): 3
vector<int> d(arr, arr+3); // 把 arr 前 3 个元素赋给 d, d.size(): 3
vector<int> e(arr[0], arr[3]); // 等价于: vector<int> e(1,4) e.size(): 1
vector<int> f(&arr[0], &arr[3]); // 把 arr 前 3 个元素赋给 f, f.size(): 3
/* 插入和删除元素 */
// f: 1 2 3
f.insert(f.begin(), 0); // 在 f.begin() 前插入元素: 0
// f: 0 1 2 3
f.insert(f.end(), 3, 1); // 在 f 末尾添加 3 个值为 1 的元素
// f: 0 1 2 3 1 1 1
f.insert(f.begin()+1, &arr[1], &arr[2]); // 将 arr[1], arr[2] 插入 f 第二个位置处
// f: 0 2 3 1 2 3 1 1 1
f.push_back(9); // 在 f 尾部插入元素: 9
// f: 0 2 3 1 2 3 1 1 1 9
f.pop_back(); // 删除 f 的末尾元素
// f: 0 2 3 1 2 3 1 1 1
f.erase(f.end()-1); // 删除 f 的末尾元素
// f: 0 2 3 1 2 3 1 1
f.erase(f.begin(), f.begin()+3); // 删除 f 前三个元素
// f: 1 2 3 1 1
f.clear(); // 清空 f (删除容器中所有元素)
f.assign(d.begin(), d.end()); // 把 d 赋给 f, 会覆盖 f 原本的内容
f = d; // 同上
/* 其他常用的成员函数 */
f.begin(); // 作用于容器: 返回首元素的迭代器; 作用于数组: 返回首元素的指针
f.end(); // 作用于容器: 返回末尾元素向后延一个的迭代器; 作用于数组: 返回尾元素下一位的指针
f.size(); // 返回容器 f 的大小
f.empty(); // 判空, 容器为空时返回: true, 否则返回: false
f.front(); // 返回第一个元素
f.back(); // 返回最后的元素
vector 有两种遍历方式:像数组一样用下标访问元素、使用迭代器访问。迭代器是一种检查容器内元素并遍历元素的数据类型,通常用于对C++中各种容器内元素的访问,但不同的容器有不同的迭代器。迭代器在某种意义上可以理解为一种指针。
/* 下标访问 */
for(int i=0; i<f.size(); i++) {
cout << f[i] << ' ';
}
/* 迭代器遍历 */
for(vector<int>::iterator it=f.begin(); it!=f.end(); it++) {
cout << *it << ' ' ;
}
扩展:二维向量,利用嵌套 vector 实现。
// 创建一个 3 行 4 列的 vector, 且值初始化为1
vector< vector<int> > g(3, vector<int>(4, 1));
// 输出这个 2D vector
for(int m=0; m<g.size(); m++) {
for(int n=0; n<g[m].size(); n++) {
cout << g[m][n] << " ";
}
cout << "\n";
}
list
列表 list,是一个双向(循环)链表,它保存了前、后指针,所以可以向前、向后双向访问,但是不能随机访问。存储空间不要求连续,可以在两头或中间的任意位置插入、删除元素,且相当高效。
list 适合大量的插入和删除操作,不过查询性能很差。
须包含头文件:#include <list>
,大多数语法格式与
vector 类似。
示例:
/* 初始化 */
list<int> lst1; // 创建空容器
list<int> lst2(5); // 创建含有 5 个元素的 list
list<int> lst3(3,2); // 创建含有 3 个元素的 list, 初始值为2
list<int> lst4(lst3); // 用 lst3 初始化 lst4
list<int> lst(lst3.begin(), lst3.end()); // 同 lst4
/* 插入和删除 (区别于 vector 的新特性) */
lst.push_front(0); // 在头部插入元素: 0
lst.push_back(4); // 在尾部插入元素: 4
lst.pop_front(); // 删除首元素
lst.pop_back(); // 删除末尾元素
/* list 须用迭代器遍历 */
for(list<int>::iterator it=lst.begin(); it!=lst.end(); it++) {
cout << *it << ' ' ;
}
/* 迭代器还可以逆向遍历 */
for(list<int>::reverse_iterator it=lst.rbegin(); it!=lst.rend(); it++) {
cout << *it << ' ' ;
}
deque
是一个双端(向)队列,介于 vector 和 list 之间,采用多个连续的内存块,是分块的链表和多个数组的联合。支持两端插入、删除元素,可以随机访问。deque 的增删功能比 vector 多,但是随机访问的速度比 vector 慢。不过 deque 的查询性能比 list 强。
deque 适用于既要随机存取、又关心两端数据的增删的情况。
须包含头文件:#include <deque>
。
deque 的语法与 vector 类似,但是可以用 list 中的一些成员函数(用于两端插入和删除)。
关联容器
用非线性的二叉树结构实现,具体的说是:平衡二叉搜索(排序)树(AVL树,以两位发明此法的俄罗斯数学家的名字命名),更具体的说是:红黑树。
各元素间没有严格的物理顺序,元素通过关键字来保存和访问,即键(key),有的还包括对应的值(value)。支持通过键来高效地查找和读取元素。关键字是有序的,默认按照升序排列。
常见的关联容器有两种:map、set。
map
映射 map,元素是由 (key, value) 两个分量组成的对偶,其中 key 是唯一的,只对应一个确定的 value。关键字有序排列,且不允许重复。支持下标操作,下标是键(key)。
map 内部按链表的方式存储元素,插入比 vector 快,但查找和在末尾添加元素比 vector 慢。
关键字(key)类型:key_type
关键字关联的(value)类型:mapped_type
map
保存的元素类型(即:pair <const key_type,
mapped_type>):value_type
pair 对象,会将两个值视为一个单元,pair 类型的对象存储的就是一个键值对。pair 是一个用来生成特定类型的模版,容器 map 就是使用 pair 来管理键值对数据元素的。
须包含头文件:#include <map>
。
示例:
// 下面三条语句等价
pair<string, string> a("Hello", "World");
pair<string, string> a = {"Hello", "World"};
pair<string, string> a {"Hello", "World"};
// map 的使用
// map 初始化
map<string, int> M {
{"11", 1},
{"22", 2}
};
// 用成员函数 insert 结合 pair 插入新元素对
M.insert(pair<string, int>("33", 3));
// 也可以用 map 中的元素类型 value_type 插入新元素对
M.insert(map<string, int>::value_type("44", 4));
// 可以用关键字下标访问元素 (输出: 3)
cout << M["33"] << endl;
// 还可以利用下标特性添加新元素对
M["55"] = 5;
// 用迭代器遍历, 输出键值对
map<string, int>::iterator it;
for(it=M.begin(); it!=M.end(); it++) {
// it->first 返回关键字: key, it->second 返回 key 对应的 value
cout << it->first << ' ' << it->second << endl;
}
// 查找 key 是否存在 (存在, 返回: 1)
cout << M.count("55") << endl;
// 下面这条命令返回指向该元素的迭代器, 若元素不存在, 则返回 end 迭代器
M.find("55");
// 删除元素对, 下面两条语句等价
M.erase("55");
M.erase(M.find("55"));
set
集合 set,一组元素的集合,是单纯的键(关键字)的集合,只包含独立的元素。键唯一。没有下标操作。
set 包含的元素也叫 set 的实例。set 内部也是通过链表的方式组织,插入元素快,但查找和末尾添元素较慢。
须包含头文件:#include <set>
。
示例:
// 用 vector 初始化 set
vector<int> v;
// size_type 是 vector<int> 里 size() 返回的类型
for(vector<int>::size_type i=0; i!= 5; i++) {
v.push_back(i);
v.push_back(i);
}
// v.size(): 10, s.size(): 5
set<int> s(v.begin(), v.end());
// 插入新元素
s.insert(5);
s.insert("三国");
容器适配器
这是一个特殊的容器,是用基本容器实现的新容器,可以描述更高级的数据结构。C++中,适配器就是使一类事物的行为类似另一类事物的行为的一种机制。容器适配器让一种已存在的容器类型采用另一种不同的抽象类型的工作方式进行实现。
可以将容器适配器理解为:容器的容器,或者容器的接口。容器适配器本身不能直接保存元素,它只是调用另一种顺序容器去实现数据的保存,因此可以这样认为:它先保存一个容器,这个容器再保存元素。
常见的容器适配器有三种:stack、queue、priority_queue。
stack
对应数据结构中的栈,先进后出、后进先出表。允许在栈顶插入、删除元素,不能访问中间元素。默认衍生自 deque,由于只涉及单端数据的增删,因此可以使用 vector、list、deque 中的任意一种实现。
须包含头文件:#include <stack>
。
示例:
// 下面两条初始化语句是等价的
stack<int> stk;
stack<int, deque<int>> stk;
// 从 vector 衍生 stack
stack<int, vector<int>> stk;
stk.push(5); // 从栈顶插入元素: 5
stk.pop(); // 删除栈顶元素
stk.top(); // 获取指向栈顶元素的引用
queue
对应数据结构中的队列,先进先出、后进后出表。只允许在队尾插入元素,队头删除元素。默认衍生自 deque,由于需要进行双头数据增删,因此关联的基本容器只能是 list、deque,不能是 vector。
须包含头文件:#include <queue>
。
示例:
queue<float> que; // 初始化
que.push(1); // 队尾插入元素: 1
que.pop(); // 删除队首元素
que.front(); // 返回指向队首元素的引用
que.back(); // 返回指向队尾元素的引用
priority_queue
带优先级的队列,一般最大值在队首,且(整型队列)会按照从大到小的顺序排列。只能删除最大值元素,也就是说,只能在队首进行删除。默认衍生自 vector,由于要提供随机访问的功能,故不能建立在 list 容器上,可以由 vector 和 deque 实现。
须包含头文件:#include <queue>
。
示例:
priority_queue<int> prq; // 初始化
prq.push(2); // 插入新元素: 2
prq.pop(); // 删除队首元素(最大值元素)
prq.top(); // 返回队列中最大元素的引用