C++中的类与对象


类与对象

1.面向对象程序设计的基本特点

  • 抽象:面向对象方法中的抽象,是指对具体问题(对象)进行概括,抽出一类对象的公共性质并加以描述的过程
    • 包括有数据抽象和功能抽象
  • 封装:将抽象得到的数据和行为(或者功能)相结合,形成一个有机的整体
  • 继承:允许程序员在保持原有类特性的基础上,进行更具体、更详细的说明
  • 多态:多态性是指一段程序能够处理多种类型对象的能力,C++可以通过强制多态重载多态类型参数化多态包含多态

2. 类的成员函数

2.1 成员函数的实现

函数的原型声明要写在类中,原型说明了函数的参数表和返回值类型。而函数的具体实现是写在定义之外的。与普通函数不同的是,在实现成员函数的时候要指明类的名称,具体形式如

#include "iostream"

using namespace std;

class Clock {
    void setTime(int newH,int newM,int newS);
};

void Clock::setTime(int newH, int newM, int newS) {
    std::cout<<newH<<newM<<newS;
}

2.2 成员函数调用中的目的对象

调用一个成员函数与普通函数的差异在于,需要使用.运算符来之处调用所针对的对象,这一对象再本次调用中称为目的对象

在类的成员函数中,既可以访问目的对象的私有成员,又可以访问当前类的其他对象的私有成员

2.3 带默认形参值的成员函数

类成员函数的默认值,一定要写在类定义之中,而不能写在类定义之外。

2.4 内联成员函数

函数的调用过程需要消耗一些内存资源和运行时间来传递参数和返回值,比如说要记录调用时的状态,以便保证调用完成后能够正确地返回并继续执行。如果有的函数成员需要被频繁调用,而且代码比较简单的,这个函数也可以定义为内联函数(inline function),内联函数的函数体也会在编译时被插入到每个调用它的地方,这样做可以减少调用的开销,提高执行效率,但是却增加了编译后代码的长度。

2.5 构造函数和析构函数

类和对象的关系就相当于基本数据类型与它的变量的关系,在定义对象的时候,也可以同时对它的数据成员进行赋值,在定义对象的时候进行的数据成员设置,称为对象的初始化。在特定对象使用结束时,还经常需要进行一些清理工作。C++程序中的初始化和清理工作由构造函数和析构函数来完成

2.5.1 构造函数

首先要理解对象的建立过程,为此先来看看一个基本类型变量的初始化过程:

每个变量在程序运行时都要占据一定的内存空间,在定义一个变量时对变量进行初始化,就意味着在为变量分配内存单元的同时,在其中写入了变量的初始值。这样的初始化在C++源程序中看似很简单,但是编译器却需要根据变量的类型自动产生一些代码来完成初始化过程。

来看到对象的建立过程:在程序执行过程中,当遇到对象定义的时候:

  • 程序会向操作系统申请一定的内存空间存放新建的对象。

类的对象比较复杂,编译器不知道如何产生代码来实现初始化

  • 如果需要进行对象的初始化,程序员需要编写初始化程序

构造函数的作用: 在对象被创建的时候利用特定的值构造对象,将对象初始化为一个特定的状态。构造函数在对象被创建的时候将被自动调用

2.5.2 默认构造函数

调用时不需要提供参数的构造函数称为默认构造函数。如果类中没有写构造函数,编译器会自动生成一个隐含的默认构造函数,但是该构造函数参数表和方法表都是空的,这是否意味着这个函数啥也不干呢?并不是。它还要负责基类的构造和成员对象的构造。

  • 编译器不包括任何构造函数的情况下才会替我们生成默认构造函数,一旦定义了其他构造函数,那么除非自己再定义一个默认的构造函数,否则构造类中将没有默认构造函数
  • 含有基本类型成员的类应该在类内部初始化这些成员,或者自定义一个默认构造函数,否则用户在创建类对象的时候就有可能得到未定义的值
  • 如果类中包含一个其他类型类型的成员,并且这个类没有默认构造函数,那么编译器就无法初始化该成员

2.5.3 委托构造函数

一个委托构造函数使用它所属类的其他构造函数执行它自己的初始化过程,也就是说它把自身的一些(或者全部)职责委托给了其他构造函数

using namespace std;

class Clock {
private:
    int h;
    int m;
    int s;
public:
    void setTime(int newH=0,int newM=0,int newS=0);
    Clock(int newH=0,int newM=0,int newS=0){

    }
    Clock():Clock(0,0,0){}
};

这时候第二个构造函数委托给了第一个构造函数来完成数据成员的初始化,当一个构造函数委托给另一个构造函数的时候,受委托的构造函数的初始值列表和函数体依次执行,然后控制权才会还给委托者函数

2.5.4 复制构造函数

复制构造函数是一种特殊的构造函数,具有一般构造函数的所有特性,其形参是本类对象的对象的引用,其作用是使用一个已存在的对象(由复制构造函数的参数来指定),去初始化同类的一个新对象

如果程序员没有定义类的复制构造函数,系统就会在必要时生成一个隐含的复制构造函数。

这个隐含的复制构造函数的功能是把初始值对象的每个数据成员的值都复制到新建立的对象中,因此也可以说是完成了同类对象的克隆,这样得到的对象和原对象具有完全相同的数据成员

普通构造函数是在对象创建时被调用,而复制构造函数在以下三种情况都会被调用

class Point {
public:
    Point(int xx = 0,int yy = 0);
    Point(Point &p);
    int getX();
    int getY();

private:
    int x;
    int y;
};

int Point::getX() {
    return this->x;
}

Point::Point(int xx, int yy) {
    this->x = xx;
    this->y = yy;
}

Point::Point(Point &p) {
    this->x = p.x;
    this->y = p.y;
    cout<<"执行了复制构造函数";
}

int Point::getY() {
    return this->y;
}
  • 当用一个类的一个对象去初始化该类的另一个对象时
Point a(1,2);
Point b(a); //用对象a初始化对象b,复制构造函数被调用
Point c= a;//用对象a初始化对象c,复制构造函数被调用
cout<<b.getX()<<endl;
return 0;

以上对b和c的初始化都能够调用复制复制构造函数,两种写法只是形式上有所不同而已

比如说Point c = a,实际上是Point c(a)

  • 如果函数的形参是类的对象,调用函数时,进行形参和实参的结合

这里首先复习一下什么叫实参什么叫形参

形参变量:形参变量是功能函数里的变量,只有被调用的时候才分配内存单元,调用结束后立即释放所以形参只有在函数内部有效

实参变量:实参可以是常量,变量,表达式,函数等,但无论是哪种类型,他们必须有确定的值,以便把这些值拷贝给形参

形参和实参在内存中有不同的位置:在函数运行时,形参和实参是不同的变量,他们在内存中是处于不同的位置的,形参将实参的内容拷贝一份,在该函数运行结束的时候释放,实参内容不变。

void f(Point p){
    std::cout<<p.getX()<<endl;
}
Point a(1,2);
f(a);//函数的形参为类的对象,当调用对象的时候,复制构造函数被调用
return 0;

只有把对象用值进行传递的时候,才会调用复制构造函数,如果传递的是引用,那么就不会调用复制构造函数。由于这一原因,传递比较大的对象的时,传递引用会比传值的效率高很多

  • 如果函数的返回值是类的对象,函数执行完成返回调用者时
Point g(){
    Point a(1,2);
    return a;//函数的返回值是类对象,返回函数值的时候,调用复制构造函数
}

为什么这种情况下,返回函数值的时候,会调用复制构造函数呢?

表面上g()a返回给了主函数,但是要记住,生成a的对象的内存空间是位于g()的内存区域中的,一旦g()函数结束,g()所得到的内存区域也会随之释放。所以在返回的时候呢,编译系统会现在主函数所属的内存空间中创建一个无名的临时对象。

该临时对象的生存期只在函数调用所处的表达式中,也就是表达式b = g()中。

执行语句return a的时候,实际上是调用复制构造函数将a的值复制到临时对象中,函数g运行结束时对象a小时,但是临时对象会存在于表达式b=g()中,计算完这个表达式之后,临时对象的使命也就完成了,该对象自动消失

关于编译器优化的问题

Point::Point(){
    cout<<"调用了拷贝构造函数"<<endl;
}
Point g(){
    Point a(1,2);
    return a;//函数的返回值是类对象,返回函数值的时候,调用复制构造函数
}
int main(){
    Point b = g();
    return 0;
}
  • 当编译器不进行优化的时候

不进行优化的时候,由于在g()结束的时候,a会随之被析构,因此,在这个时候需要在main()中使用复制构造函数,将这个对象在main()中保留一个无名的对象。

然后这个无名的对象作为参数进行b的复制构造函数初始化,因此一共执行了两次复制构造函数,因此输出结果为:

调用了拷贝构造函数
调用了拷贝构造函数
  • 当编译器进行优化的时候

主函数创建一段内存空间用于存放返回的对象,主函数调用被调用的函数的时候,将该内存空间传入到被调函数中,在主调函数分配内存,在被调函数中进行构造,在这个情况下分析这段代码的执行过程:

其根本做法是消除了中间对象,避免了不必要的复制构造,由于在传入了一块专用的内存空间,因此在构造a的时候(因为a是返回值),这时候会直接将a写入到main()的栈空间中,然后回到main()之后,执行相应的计算,也就是

调用了拷贝构造函数

少去那一步创建匿名对象的过程

另外,当类的数据成员中有指针类型的时候,默认的复制构造函数实现的只能是浅复制,浅复制会带来数据安全方面的问题,深复制必须编写复制构造函数。

2.5.5 析构函数

构造对象时,在构造函数中分配了资源,例如动态申请了一些内存资源,在对象消失的实时就要释放这些内存单元。析构函数用来完成对象被删除前的一些清理工作,析构函数是在对象的生存期即将结束的时刻被自动调用的,它的调用完成以后,对象也就消失了,相应的内存空间也被释放

一般来讲,如果希望程序在对象被删除之前的时刻自动完成某些事情,就可以把他们写到析构函数之中

2.5.6 移动构造函数

C++11标准引入了左值和右值,定义了右值引用的概念,以表明被引用对象在使用后会被销毁,不会继续使用

直观来看,左值是位于赋值语句左侧的对象变量,右值是赋值语句右侧的值而不依附于对象

参数引用传递中对持久存在变量的引用,称之左值引用,相对的对短暂存在可被移动的右值引用称为右值引用。

可以通过移动右值的引用对象来安全地构造新对象

float n= 6;
float &lr_n=n;//对变量n的左值引用
//float &&rr_n=n;错误,不能将右值引用绑定到左值上
float &&rr_n=n*n;//将乘法结果右值绑定到右值引用
//float &lr_n=n*n;错误,不能将左值引用绑定到乘法结果右值

基于右值引用,移动构造函数通过移动数据方式来构造新对象,与复制构造函数类似,移动构造函数参数为该类对象的右值引用

class astring{
public:
    std::string s;
    astring(astring&& o)noexcept:s(std::move(o.s)){
        //显式移动所有成员
    }
};

移动构造函数不分配新内存,理论上不会报错,为了配合异常捕获机制,需要声明noexcept表明不会抛出异常

被移动的对象不应再使用,需要销毁或者重新赋值

左值用的是对象,右值用的是内容

关于const

用const的左值可以引用右值

但是右值值能引用纯右值

2.5.7 default、delete函数

  • default:使用=default可以让编译器合成简单的无参默认构造函数和复制构造函数
  • delete:当用户不希望定义的类存在复制时,可以通过delete进行删除,与default使用不同的是,delete不限于在无参和复制构造函数上使用,除了析构函数外

2.5.8 关于构造函数、复制构造函数和析构函数的例程解析

  • 例程分析
#include <iostream>
using namespace std;
class Test{
private:
    int x;
public:
    Test(int x){//有参构造函数,指代当前类的x
        this->x = x;
        cout<<x<<" parameter constructed"<<endl;
    }
    Test(){//无参构造函数,指代当前类的x
        x = 0;
        cout<<x<<" default constructed"<<endl;
    }
    Test(Test& t){//复制构造函数
        x = 0;
        cout<<x<<" copy constructed"<<endl;
    }
    Test(const Test& t){
        x = 1;
        cout<<x<<" const copy constructed"<<endl;
    }
    void init(int x){
        this->x = x;
        cout<<x<<" init constructed"<<endl;
    }
    ~Test(){
        cout<<x<<" destroied"<<endl;
    }
    int getX(){
        return x;
    }
    void fun(Test t);

    Test add(Test t);

    Test add1(Test s);
};

void Test::fun(Test t) {
    cout<<t.x<<" excuted"<<endl;
}

Test Test::add(Test t){
    x = t.x +20;
    cout<<x<<" excuted"<<endl;
    return t;
}

Test Test::add1(Test t){
    x = t.x+40;
    cout<<x<<" excuted"<<endl;
    return t;
}
Test t1;//调用无参构造函数输出(1)0 default constructed => t1.x = 0

int main(){
    Test a(10);//调用有参构造函数输出(2)10 parameter constructed => a.x = 10	
    Test b(a);//调用无参构造函数输出(3)0 copy constructed => b.x=0
    a.fun(a);	
    //调用时,先调用复制构造函数将a复制给add函数内临时对象t
    //因为临时对象只存在于函数内,所以当调用函数结束的时候,临时对象t调用析构函数销毁
    //先将临时变量构造出来(4)0 copy constructed
    //然后执行计算(5)0 excuted
    //fun()方法结束,执行析构函数析构t:(6)0 destroied
    b.add(b);	
    //同样的,是一个传值类型的传递,因此需要在函数内部创建临时对象t.x=0
    //(7)0 copy constructed
    //然后b.x = t.x +20,b.x=20
    //(8)20 excuted
    //随着花括号结束,需要将返回值临时对象再主函数中临时创建
    //(9)0 copy constructed 将函数中的临时对象复制到主函数的临时对象中
    //然后花括号结束,删除临时对象t,t.x=0,于是
    //(10)0 destroied
    //然后回到主函数,临时对象的存活时间只在调用函数的那一条语句中,如果发现没人用,那么就直接销毁掉了
    //(11)0 destroied
    cout<<endl<<endl;
    Test c= b.add1(a);
    //和上面一样,add1(a)是一个传值调用
    //于是先再函数内部把t构造出来
    //(12)0 copy constructed(此时t.x=0)
    //然后执行计算
    //(13)40 excuted(此时b.x=40)
    //然后和之前一样,不优化返回值,在函数结束之前要先把临时对象给删除,但是要先把这个对象x在主函数中构造出来
    //(14)0 copy constructed
    //然后把这个临时对象t删除
    //(15)0 destroied
    //然后出来到主函数,主函数呢就要把x对象赋值给c,同样的是一个复制构造   
    //(16)0 copy constructed
    //接着这条语句结束,释放临时对象x
    //(17)0 destroied
    //然后开始各个对象的析构,析构的顺序是按照对象创建入栈的顺序,出栈析构的
    //(18)0 destroied(销毁c)
    //(19)40 destroied(销毁b)
    //(20)10 destroied(销毁a)
    //(21)0 destroied(销毁t1)
}
  • 以上分析是基于编译器不优化返回值的情况的,这并不是说所有的返回值在主函数的临时对象都会被取消,而是根据需要取消

比如说最后一步,没有优化之前是怎么做的咧?是先建一个临时对象,然后再根据这个临时对象,再建一个我们真的需要的c,那么我们为什么不直接把这个临时对象作为c呢?

基于这个思路,编译器直接在实例的首地址开始创建对象而不必创建临时对象,也就少了一步复制和销毁

少的应该是第(16)(17)句

3. 类的组合

类的组合机制:在定义一个新类的时候,可以把用已有类的对象作为成员

3.1 组合的概念

  • 类中的成员是另一个类的对象
  • 可以在已有抽象的基础上实现更复杂的抽象

3.2 类组合的构造函数设计

  • 不仅负责对本类中的基本类型成员数据初始化,也要对对象成员做初始化
类名::类名(对象成员所需要的形参,本类成员形参)
    	:对象1 (参数),对象2 (参数),...{}

手段:指定它每个对象成员的参数

首先对构造函数初始化列表中列出的成员(包括基本类型成员和对象成员)进行初始化,初始化次序是成员在类体中定义的次序

  • 成员对象构造函数调用顺序:按对象成员的声明顺序,先声明者先构造
  • 初始化列表中未出现的成员对象,调用默认构造函数(也就是没有形参)的初始化

处理完初始化列表之后,再执行构造函数的函数体

4. 前向引用声明

  • 类应该先声明,后使用
  • 如果需要在某个类的声明之前,引用该类,则应该进行前向引用声明
  • 前向引用声明只为程序引入一个标识符,但具体声明在其他地方
class B;
class A{
public:
    void f(B b);
};
public B{
public:
    void f(A a);
};

只所以可以像上面这样写的原因是:形参只有在被调用的时候,才会知道它的内部实现细节,如果只是声明形参的话就不会报错,根本原因是形参的作用域在函数原型声明中,它是具有函数原型声明作用域的,在此处就做一下语法检查,在这里就只只需要知道它的类型即可


文章作者: 穿山甲
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 穿山甲 !
  目录