初识C++03:引用、继承与派生

引用、继承与派生

引用介绍

首先外面要知道:参数的传递本质上是一次赋值的过程,赋值就是对内存进行拷贝。所谓内存拷贝,是指将一块内存上的数据复制到另一块内存上,对于聚合类 型(复杂类型,类似结构体和类这些)消耗的内存可能会非常大。

创新互联公司是一家专注于成都做网站、网站建设与策划设计,桥西网站建设哪家好?创新互联公司做网站,专注于网站建设十余年,网设计领域的专业建站公司;建站业务涵盖:桥西等地区。桥西做网站价格咨询:18982081108

引用可以看做是数据的一个别名,通过这个别名和原来的名字都能够找到这份数据(指向同一个内存)

注意:

  • 引用必须在定义的同时初始化,并且以后也要从一而终,不能再引用其它数据,这有点类似于常量(const 变量)
  • 引用在定义时需要添加&,在使用时不能添加&,使用时添加&表示取地址
int a = 99;
int &r = a;
cout << a << ", " << r << endl;

一般c++中,引用作为函数参数,代替了指针的功能,一样达到改变数据内容的效果, 非常实用;

同时c++中,引用可以作为函数返回值,但是!!!不能返回局部数据(例如局部变量、局部对象、局部数组等)的引用,因为当函数调用完成后局部数据就会被销毁,有可能在下次使用时数据就不存在了

int &plus10(int &r) {
    int m = r + 10;
    return m;  //返回局部数据的引用
}
//对于一些编译器,就会报错
int &num3 = plus10(num1);
int &num4 = plus10(num3);
//但是有些编译器是可以运行的,比如gcc,但是num3和num4的值是一样的,因为 函数是在栈上运行的,并且运行结束后会放弃对所有局部数据的管理权,后面的函数调用会覆盖前面函数的局部数据,两个指向的地方改成了最后一个值;

引用的本质:

其实引用只是对指针进行了简单的封装,它的底层依然是通过指针实现的,引用占用的内存和指针占用的内存长度一样,在 32 位环境下是 4 个字节,在 64 位环境下是 8 个字节,之所以不能获取引用的地址,是因为编译器进行了内部转换:

int a = 99;
int &r = a;
r = 18;
cout<<&r<

&r取地址时,编译器会对代码进行隐式的转换,使得代码输出的是 r 的内容(a 的地址),而不是 r 的地址,这就是为什么获取不到引用变量的地址的原因。也就是说,不是变量 r 不占用内存,而是编译器不让获取它的地址。

指针和引用的其他区别:

  • 引用必须在定义时初始化,并且以后也要从一而终,不能再指向其他数据;而指针没有这个限制,指针在定义时不必赋值,以后也能指向任意数据

  • 可以有 const 指针,但是没有 const 引用,r 本来就不能改变指向,加上 const 是多此一举。

  • 指针可以有多级,但是引用只能有一级(学了引用折叠可以想想还正不正确),例如,int **p是合法的,而int &&r是不合法的(c++11增加右值引用,合法),下面这个是可以的

    int a = 10;
    int &r = a;
    int &rr = r;
    //都是指向a的地址
    
  • 指针和引用的自增(++)自减(--)运算意义不一样。对指针使用 ++ 表示指向下一份数据,对引用使用 ++ 表示它所指代的数据本身加 1

引用一般不能绑定临时数据:

指针和引用只能指向内存,不能指向寄存器或者硬盘,因为寄存器和硬盘没法寻址。

定义的变量、创建的对象、字符串常量、函数形参、函数体本身、newmalloc()分配的内存等,这些内容都可以用&来获取地址。

什么数据不能用&,它是会在寄存器:

  • int、double、bool、char 等基本类型的数据往往不超过 8 个字节,用一两个寄存器就能存储,所以这些类型的临时数据通常会放到寄存器中;而对象、结构体变量是自定义类型的数据,大小不可预测,所以这些类型的临时数据通常会放到内存中
int *p2 = &(n + 100);//不行,n+100会在寄存器中,常量表达式也在寄存器中
S s1 = {23, 45};
S s2 = {90, 75};
S *p1 = &(s1 + s2);//visualC++中可以,s1+s2在内存中,但是!!!!gcc不行,因为gcc不能指代任何临时变量!!!

bool isOdd(int &n){
    if(n%2 == 0){
        return false;
    }else{
        return true;
    }
}
isOdd(a);  //正确
isOdd(a + 9);  //错误,有时候很容易给它传递临时数据

要去看看

const引用绑定临时数据:

常引用:编译器会为临时数据创建一个新的、无名的临时变量,并将临时数据放入该临时变量中,然后再将引用绑定到该临时变量。

改为常引用即可,因为 为普通引用创建临时变量没有任何意义,创建的临时变量,修改的也仅仅是临时变量里面的数据,不会影响原来的数据,意义不大。 而常引用,我们只能通过 const 引用读取数据的值,而不能修改它的值,所以不用考虑同步更新的问题,也不会产生两份不同的数据。

bool isOdd(const int &n){  //改为常引用
    if(n/2 == 0){
        return false;
    }else{
        return true;
    }
}
isOdd(7 + 8);  //正确
isOdd(a + 9);  //正确

const引用与类型转换:

指针类型转换是错误的(意想不到的错误hhh),因为不同类型的数据占用的内存数量不一样,处理方式也不一样(可以看看《整数在内存中是如何存储的》《小数在内存中是如何存储的》借鉴一下);因为引用的本质也是指针,所以引用的类型转换也是错误的。

int n = 100;
int *p1 = &n;  //正确
float *p2 = &n;  //错误
int &r1 = n;  //正确
float &r2 = n;  //错误

但是!!!加常引用就可以发生类型转换

原理:引用的类型和数据的类型不一致时,如果它们的类型是相近的,并且遵守「数据类型的自动转换」规则,那么编译器就会创建一个临时变量,并将数据赋值给这个临时变量(这时候会发生自动类型转换),然后再将引用绑定到这个临时的变量,这与「将 const 引用绑定到临时数据时」采用的方案是一样的。

int n = 100;
int &r1 = n;  //正确
const float &r2 = n;  //正确

综上:如果函数不要求改变所引用的值,函数形参尽量使用const;一来避免临时数据,二来避免非同类型,三来const和非const实参都可以接受;

double volume(const double &len, const double &width, const double &hei){
    return len*width*2 + len*hei*2 + width*hei*2;
}
double v4 = volume(a+12.5, b+23.4, 16.78);
double v5 = volume(a+b, a+c, b+c);

继承和派生

继承介绍

被继承的类称为父类或基类,继承的类称为子类或派生类。“子类”和“父类”通常放在一起称呼,“基类”和“派生类”通常放在一起称呼。是同一个意思。

什么时候用继承:肯定是类与类之间有很大关联,有很多共同成员函数和成员变量啦。

继承过来的成员,可以通过子类对象访问,就像自己的一样。

继承格式:

class 派生类名:[继承方式] 基类名{
派生类新增加的成员
};

三种继承方式

public继承方式

  • 基类中public成员->派生类中变为public属性
  • 基类中protected成员->派生类中还是protected属性
  • 基类中private成员->派生类中不能用,不可见的

protected继承方式

  • 基类中public成员->派生类中变为protected属性
  • 基类中protected成员->派生类中还是protected属性
  • 基类中private成员->派生类中不能用,不可见的

private继承方式

  • 基类中public\protected->派生类中变为private属性,在派生类中只能在类中使用
  • 基类中private成员->派生类中不能用,不可见的

protected属性只有在派生类中(类代码中) 才能访问;其他都不可以;

不难发现:

1)继承方式中的 public、protected、private 是用来指明基类成员在派生类中的最高访问权限的,是不可以超过的,即即使基类中是public成员属性,派生类中采用protected继承,那public成员属性也只能变成protected;

2)基类中的 private 成员在派生类中始终不能使用,但是可以通过public的set和get函数使用(在派生类中访问基类 private 成员的唯一方法就是借助基类的非 private 成员函数)

3)如果希望基类的成员能够被派生类继承并且毫无障碍地使用,那么这些成员只能声明为 public 或 protected

4)如果希望基类的成员既不向外暴露(不能通过对象访问),还能在派生类中使用,那么只能声明为 protected

注意????我们这里说的是基类的 private 成员不能在派生类中使用,并没有说基类的 private 成员不能被继承。实际上,基类的 private 成员是能够被继承的,并且(成员变量)会占用派生类对象的内存,它只是在派生类中不可见,导致无法使用罢了。

改变访问权限的方法,用using关键字

using 只能改变基类中 public 和 protected 成员的访问权限,不能改变 private 成员的访问权限,因为基类中 private 成员在派生类中是不可见的,写不了;

//基类People
class People {
public:
    void show();
protected:
    char *m_name;
    int m_age;
};
void People::show() {
    cout << m_name << "的年龄是" << m_age << endl;
}
//派生类Student
class Student : public People {
public:
    void learning();
public:
    using People::m_name;  //将protected改为public
    using People::m_age;  //将protected改为public
    float m_score;
private:
    using People::show;  //将public改为private
};

继承时的名字遮蔽

对于函数,不管函数的参数如何,只要名字一样就会造成遮蔽,不会有重载,如果想要调用就用域名和域解析符;

对于成员变量,只要名字一样,派生类的会遮蔽基类,但是基类的成员变量一样时存在的,此时从基类中继承的get和set方法都是对基类同名变量的操作,不会是对派生类的同名变量操作

类继承的作用域嵌套和对象内存模型

假设 Base 是基类,Derived 是派生类,那么它们的作用域的嵌套关系会有:

编译器会在当下类作用域从内找到外:

通过 obj (c类对象)访问成员变量 n 时,在 C 类的作用域中就能够找到了 n 这个名字。虽然 A 类和 B 类都有名字 n,但编译器不会到它们的作用域中查找,所以是不可见的,也即派生类中的 n 遮蔽了基类中的 n。

通过 obj 访问成员函数 func() 时,在 C 类的作用域中没有找到 func 这个名字,B没找到,再继续到 A 类的作用域中查找,结果就发现了 func 这个名字,查找结束,编译器决定调用 A 类作用域中的 func() 函数(这个过程叫名字查找,都是通过名字查找,除非直接通过域名和域解析符去找,就不会有这个过程);

对象内存模型:

无继承的时候比较简单,变量存在堆或者栈区,函数存在代码段;

存在继承时:

所有变量连续存在堆区或者栈区(成员变量按照派生的层级依次排列,新增成员变量始终在最后,而且private的、遮掩的也会在内存中),函数存在代码区(所有对象共享,但是能不能用也要看它的权限,如果时private也用不了)

例子:

obj_a 是基类对象,obj_b 是派生类对象。假设 obj_a 的起始地址为 0X1000,那么它的内存分布如下图所示:

假设 obj_b 的起始地址为 0X1100,a类中的m_b是private,那么它的内存分布如下图所示:

假设 obj_c 的起始地址为 0X1300,存在遮掩的情况,那么它的内存分布如下图所示:

总结:在派生类的对象模型中,会包含所有基类的成员变量。这种设计方案的优点是访问效率高,能够在派生类对象中直接访问基类变量,无需经过好几层间接计算。

基类和派生类的构造/析构函数

在设计派生类时,对继承过来的成员变量的初始化工作也要由派生类的构造函数完成,但是大部分基类都有 private 属性的成员变量,它们在派生类中无法访问,更不能使用派生类的构造函数来初始化。解决这个问题的思路是:在派生类的构造函数中调用基类的构造函数。

#include
using namespace std;
//基类People
class People{
protected:
    char *m_name;
    int m_age;
public:
    People(char*, int);
};
People::People(char *name, int age): m_name(name), m_age(age){}

//派生类Student
class Student: public People{
private:
    float m_score;
public:
    Student(char *name, int age, float score);
    void display();
};
//People(name, age)就是调用基类的构造函数
Student::Student(char *name, int age, float score): People(name, age), m_score(score){ }
void Student::display(){
    cout<

People(name, age)就是调用基类的构造函数,并将 name 和 age 作为实参传递给它,m_score(score)是派生类的参数初始化表。其次,m_score(score)放在前面也没有问题,它都会遵循先调用基类构造函数再执行参数初始化表中的其他成员变量初始化

构造函数调用顺序:

当A->B->C类, 执行的顺序是 A类构造函数 --> B类构造函数 --> C类构造函数,A是C的间接基类,B才是C的直接基类; 派生类构造函数中只能调用直接基类的构造函数,不能调用间接基类的,因为C掉用B类的构造函数,B中又会先去调用A类的构造函数, 相当于 C 间接地(或者说隐式地)调用了 A 的构造函数,如果再在 C 中显式地调用 A 的构造函数,那么 A 的构造函数就被调用了两次,相应地,初始化工作也做了两次,这不仅是多余的,还会浪费CPU时间以及内存。

基类构造函数调用规则:

通过派生类创建对象时必须要调用基类的构造函数,这是语法规定。换句话说,定义派生类构造函数时最好指明基类构造函数;如果不指明,就调用基类的默认构造函数(不带参数的构造函数);如果没有默认构造函数。

#include 
using namespace std;
//基类People
class People{
public:
    People();  //基类默认构造函数
    People(char *name, int age);
protected:
    char *m_name;
    int m_age;
};
People::People(): m_name("xxx"), m_age(0){ }
People::People(char *name, int age): m_name(name), m_age(age){}

//派生类Student
class Student: public People{
public:
    Student();
    Student(char*, int, float);
public:
    void display();
private:
    float m_score;
};
Student::Student(): m_score(0.0){ }  //派生类默认构造函数
Student::Student(char *name, int age, float score): People(name, age), m_score(score){ }
void Student::display(){
    cout<

创建对象 stu1 时,执行派生类的构造函数Student::Student(),它并没有指明要调用基类的哪一个构造函数,从运行结果可以很明显地看出来,系统默认调用了不带参数的构造函数,也就是People::People()

创建对象 stu2 时,执行派生类的构造函数Student::Student(char *name, int age, float score),它指明了基类的构造函数。

对于析构函数

析构函数也不能被继承。与构造函数不同的是,在派生类的析构函数中不用显式地调用基类的析构函数,因为每个类只有一个析构函数,编译器知道如何选择,无需我们干涉。

析构函数的执行顺序和构造函数的执行顺序也是刚好相反:

  • 创建派生类对象时,构造函数的执行顺序和继承顺序相同,即先执行基类构造函数,再执行派生类构造函数
  • 而销毁派生类对象时,析构函数的执行顺序和继承顺序相反,即先执行派生类析构函数,再执行基类析构函数

多继承和多继承的对象内存模型

c++不仅有单继承,还有多继承

class D: public A, private B, protected C{
//类D新增加的成员
}

D 是多继承形式的派生类,它以公有的方式继承 A 类,以私有的方式继承 B 类,以保护的方式继承 C 类。D 根据不同的继承方式获取 A、B、C 中的成员,确定它们在派生类中的访问权限。

多继承下的构造:

基类构造函数的调用顺序和和它们在派生类构造函数中出现的顺序无关,而是和声明派生类时基类出现的顺序相同(和类中的变量相似),像上面的例子就会先构造A,再构造B,然后是C,最后是D;

命名冲突:

当两个或多个基类中有同名的成员(成员变量或成员函数)时,如果直接访问该成员,就会产生命名冲突,编译器不知道使用哪个基类的成员。这个时候需要在成员名字前面加上类名和域解析符::,以显式地指明到底使用哪个类的成员,消除二义性。

内存模型

直接上例子:

#include 
using namespace std;
//基类A
class A{
public:
    A(int a, int b);
protected:
    int m_a;
    int m_b;
};
A::A(int a, int b): m_a(a), m_b(b){ }
//基类B
class B{
public:
    B(int b, int c);
protected:
    int m_b;
    int m_c;
};
B::B(int b, int c): m_b(b), m_c(c){ }
//派生类C
class C: public A, public B{
public:
    C(int a, int b, int c, int d);
public:
    void display();
private:
    int m_a;
    int m_c;
    int m_d;
};
C::C(int a, int b, int c, int d): A(a, b), B(b, c), m_a(a), m_c(c), m_d(d){ }
void C::display(){
    printf("A::m_a=%d, A::m_b=%d\n", A::m_a, A::m_b);
    printf("B::m_b=%d, B::m_c=%d\n", B::m_b, B::m_c);
    printf("C::m_a=%d, C::m_c=%d, C::m_d=%d\n", C::m_a, C::m_c, m_d);
}
int main(){
    C obj_c(10, 20, 30, 40);
    obj_c.display();
    return 0;
}

借助指针突破访问权限

想想指针 指向的是内存的地址,对象指针指向的是对象的内存地址,而通过内存模型可以知道private也是在连续的内存中,所以!!!只要使用指针偏移就可以强行访问private成员变量;例如:

图中假设 obj 对象的起始地址为 0X1000,m_a(public)、m_b(public)、m_c (private)与对象开头分别相距 0、4、8 个字节,我们将这段距离称为偏移(Offset)

要知道:

int b = p -> m_b;

会转换成:int b = * (int*)( (int)p + sizeof(int) );

实际上就是:int b = * (int*) ( (int)p + 4 );

有:

所以:int c = * (int* )( (int)p + sizeof(int)*2 );//就是这么简单

虚继承

1.什么是虚继承和虚基类

多继承容易产生命名冲突:经典的菱形继承

第一类问题:在一个派生类中保留间接基类的多份同名成员,虽然可以在不同的成员变量中分别存放不同的数据,但大多数情况下这是多余的:因为保留多份成员变量不仅占用较多的存储空间,还容易产生命名冲突。假如类 A 有一个成员变量 a,那么在类 D 中直接访问 a 就会产生歧义,编译器不知道它究竟来自 A -->B-->D 这条路径,还是来自 A-->C-->D 这条路径。

为了消除歧义,我们可以在 m_a 的前面指明它具体来自哪个类(用域来处理)

但是内存中还是存在两份间接基类,是消耗内存的,所以为了解决多继承时的命名冲突和冗余数据问题,就 提出了虚继承,使得在派生类中只保留一份间接基类的成员

//间接基类A
class A{ //虚基类
protected:
    int m_a;
};
//直接基类B
class B: virtual public A{  //虚继承
protected:
    int m_b;
};
//直接基类C
class C: virtual public A{  //虚继承
protected:
    int m_c;
};
//派生类D
class D: public B, public C{
public:
    void seta(int a){ m_a = a; }  //正确
    void setb(int b){ m_b = b; }  //正确
    void setc(int c){ m_c = c; }  //正确
    void setd(int d){ m_d = d; }  //正确
private:
    int m_d;
};
int main(){
    D d;
    return 0;
}

虚继承的目的是让某个类做出声明,承诺愿意共享它的基类。其中,这个被共享的基类就称为虚基类。

可以看出一个问题:必须在虚派生的真实需求出现前就已经完成虚派生的操作,即在出现D的需求前,就要把B、C设定成虚继承;

即是虚派生只影响从指定了虚基类的派生类中进一步派生出来的类(继承BC的类,如E继承B,F继承C,就会有所影响),它不会影响派生类本身(BC);

实际开发中,位于中间层次的基类将其继承声明为虚继承一般不会带来什么问题。使用虚继承的类层次是由一个人或者一个项目组一次性设计完成,这样不需要因为没有考虑到后面的需求而重新修改中间的函数; c++库iostream就是采用虚继承。

2.虚基类成员的的可见性

以菱形继承为例,假设 A 定义了一个名为 x 的成员变量,当我们在 D 中直接访问 x 时,会有三种可能性:

  • 如果 B 和 C 中都没有 x 的定义,那么 x 将被解析为 A 的成员,此时不存在二义性。
  • 如果 B 或 C 其中的一个类定义了 x,也不会有二义性,派生类的 x 比虚基类的 x 优先级更高
  • 如果 B 和 C 中都定义了 x,那么直接访问 x 将产生二义性问题。

第二类问题:就是BC中含有优先级相等的相同变量,这个时候只能用域解析来去除二义性。

不提倡在程序中使用多继承!!!只有在比较简单和不易出现二义性的情况或实在必要时才使用多继承,能用单一继承解决的问题就不要使用多继承。小伙子,这不是你能驾驭的????????????

3.虚继承的构造函数和内存模型:

与继承时的构造过程不同,最终派生类的构造函数必须要调用虚基类的构造函数

#include 
using namespace std;
//虚基类A
class A{
public:
    A(int a);
protected:
    int m_a;
};
A::A(int a): m_a(a){ }
//直接派生类B
class B: virtual public A{
public:
    B(int a, int b);
public:
    void display();
protected:
    int m_b;
};
B::B(int a, int b): A(a), m_b(b){ }
void B::display(){
    cout<<"m_a="<class A{
protected:
    int m_a1;
    int m_a2;
};
class B: public A{
protected:
    int b1;
    int b2;
};
class C: public B{
protected:
    int c1;
    int c2;
};
class D: public C{
protected:
    int d1;
    int d2;
};
int main(){
    A obj_a;
    B obj_b;
    C obj_c;
    D obj_d;
    return 0;
}

A类所处的内存位置一直在前头

1)修改上面的代码,使得 A 是 B 的虚基类:

class B: virtual public A

A会移动到后面

2)再假设 A 是 B 的虚基类,B 又是 C 的虚基类

从上面的两张图中可以发现,虚继承时的派生类对象被分成了两部分:

  • 不带阴影的一部分偏移量固定,不会随着继承层次的增加而改变,称为固定部分;
  • 带有阴影的一部分是虚基类的子对象,偏移量会随着继承层次的增加而改变,称为共享部分

而有一个问题:如何计算共享部分的偏移量?

对于虚继承,将派生类分为固定部分和共享部分,并把共享部分放在最后,几乎所有的编译器都在这一点上达成了共识。主要的分歧就是如何计算共享部分的偏移,百花齐放,没有统一标准。

这里举例出VS的解决办法:

VC 引入了虚基类表,如果某个派生类有一个或多个虚基类,编译器就会在派生类对象中安插一个指针,指向虚基类表。虚基类表其实就是一个数组,数组中的元素存放的是各个虚基类的偏移字节数。

假设 A 是 B 的虚基类,同时 B 又是 C 的虚基类,那么各对象的内存模型如下图所示:

虚继承表中保存的是所有虚基类(包括直接继承和间接继承到的)相对于当前对象的偏移,这样通过派生类指针访问虚基类的成员变量时,不管继承层次都多深,只需要一次间接转换就可以。

这种方案还可以避免有多个虚基类时让派生类对象额外背负过多的指针,只需要背负一个指针即可。例如,假设 A、B、C、D 类的继承关系为:

内存模型为:

将派生类赋值给基类

发生数据类型转换时, int 类型的数据赋值给 float 类型的变量时,编译器会先把 int 类型的数据转换为 float 类型再赋值;类似的,类也可以发生数据类型转换,它也是一种数据类型;

不过这种转换只有在基类和派生类之间才有意义,并且只能将派生类赋值给基类,包括将派生类对象赋值给基类对象、将派生类指针赋值给基类指针、将派生类引用赋值给基类引用,这在 C++ 中称为向上转型(Upcasting)。相应地,将基类赋值给派生类称为向下转型。

上转型时非常安全的。

赋值的本质是将现有的数据写入已分配好的内存中,对象的内存只包含了成员变量,所以对象之间的赋值是成员变量的赋值,成员函数不存在赋值问题。

这种转换关系是不可逆的,只能用派生类对象给基类对象赋值,而不能用基类对象给派生类对象赋值。(因为基类不包含派生类的成员变量,无法对派生类的成员变量赋值。同理,同一基类的不同派生类对象之间也不能赋值)。

#include 
using namespace std;
//基类
class A{
public:
    A(int a);
public:
    void display();
public:
    int m_a;
};
A::A(int a): m_a(a){ }
void A::display(){
    cout<<"Class A: m_a="<

除了派生类对象赋值给类基类对象,还可以将派生类指针赋值给基类指针:

下列继承关系:

#include 
using namespace std;
//基类A
class A{
public:
    A(int a);
public:
    void display();
protected:
    int m_a;
};
A::A(int a): m_a(a){ }
void A::display(){
    cout<<"Class A: m_a="<//改改上述main函数中的内容
int main(){
    D d(4, 40, 400, 4000);
   
    A &ra = d;
    B &rb = d;
    C &rc = d;
   
    ra.display();
    rb.display();
    rc.display();
    return 0;
}

果然,运行结果:
Class A: m_a=4
Class B: m_a=4, m_b=40
Class C: m_c=400

具体分析都和指针一样;

可以去看看这个博客加强向上转型的理解


本文题目:初识C++03:引用、继承与派生
文章起源:http://pwwzsj.com/article/dsoipgj.html