【C++私房菜】类和对象详解

目录

一、类与对象

1、类是什么

二、类和对象的基础知识

2.1 定义类:成员变量和成员函数

2.2 创建对象:实例化一个类的对象。

2.3对象的生命周期:构造函数和析构函数。

a. 构造函数

b. 析构函数

c.小结:

三、成员变量和成员函数

3.1、成员变量:类的静态成员

a. 声明静态成员

b. 定义静态成员

3.2、成员函数:成员函数的定义和使用

3.3、访问控制:public、private和protected关键字的使用

四、类的其他特性

4.1 对象中的this指针

4.2 拷贝与赋值:深拷贝与浅拷贝

4.3 友元关系

a. 友元函数

b. 友元类和友元成员函数


一、类与对象

1、类是什么

在 C++中,我们通过定义一个类(class)来定义自己的数据结构。一个类定义了一个类型,以及与其关联的一组操作。类机制是 C++最重要的特性之一。实际上,C++最初的一个设计焦点就是能定义使用上像内置类型一样自然的类类型(class type)。类是 C++中面向对象编程(OOP)的核心概念之一。

类是用户定义的一种数据类型。要定义类,需要描述它能够表示什么信息和可对数据执行哪些操作。类之于对象就像类型之于变量。也就是说,类定义描述的是数据格式及其用法,而对象则是根据数据格式规范创建的实体。换句话说,如果说类就好比所有编程语言,则对象就好比其中某个语言,如我们本文要描述的C++。我们来扩展这种类比,表示编程语言的类中包括该类可执行的操作的定义,如使用此编程语言我们可以实现的功能。如果了解其他 OOP 术语,就知道 C++类对应于某些语言中的对象类型,而 C++对象对应于对象实例或实例变量。

类描述了一种数据类型的全部属性(包括可使用它执行的操作),对象是根据这些描述创建的实体。

二、类和对象的基础知识

2.1 定义类:成员变量和成员函数

类是一种将抽象转换为用户定义类型的 C++工具,它将数据表示和操纵数据的方法组合成一个整洁的包。下面来看一个表示股票的类。

首先,必须考虑如何表示股票。可以将一股作为基本单元,定义一个表示一股股票的类。然而,这意味着需要 100 个对象才能表示 100 股,这不现实。相反,可以将某人当前持有的某种股票作为一个基本单元,数据表示中包含他持有的股票数量。一种比较现实的方法是,必须记录最初购买价格和购买日期(用于计算纳税)等内容。另外,还必须管理诸如如拆股等事件。首次定义类就考虑这么多因素有些困难,因此我们对其进行简化。具体地说,应该将可执行的操作限制为:

  • 获得股票;

  • 增持;

  • 卖出股票;

  • 更新股票价格;

  • 显示关于所持股票的信息。

可以根据上述清单定义 stock 类的公有接口。为支持该接口需要存储一些信息。

接下来定义类。一般来说,类规范由两个部分组成。类声明:以数据成员的方式描述数据部分,以成员函数(被称为方法)的方式描述公有接口类。方法定义:描述如何实现类成员函数。简单地说,类声明提供了类的蓝图,而方法定义则提供了细节。

C语言是面向过程的,关注的是过程,分析出求解问题的步骤,通过函数调用逐步解决问题。而C++是基于面向对象的,关注的是对象,将一件事情拆分成不同的对象,靠对象之间的交互完成。

C语言结构体中只能定义变量,在C++中,结构体内不仅可以定义变量,也可以定义函数。比如: 之前在数据结构初阶中,用C语言方式实现的栈,结构体中只能定义变量;现在以C++方式实现, 会发现 struct 中也可以定义函数。

简单地说,类声明提供了类的蓝图,而方法定义则提供了细节。

 class name
 {
     //....类体:由成员函数和成员变量组成
     
 };// 一定要注意后面的分号

其中class为定义类的关键字,name 为类的名字,{}中为类的主体,注意类定义结束时后面分 号不能省略。 类体中内容称为类的成员:类中的变量称为类的属性或成员变量; 类中的函数称为类的方法或者成员函数。

定义类有两种方式:

  1. 声明和定义全放在类体中。

  2. 声明在类内,定义在类外。如类声明置于.h文件中,成员函数放在.cpp中。

 //1、声明和定义全放在类体中。
 class Person
 {
 public:
     void show(){
         cout<<_name<<" "<<_age<<endl;
     }
 private:
     char* _name;
     int _age;
 };
 ​
 //2、声明在类内,定义在类外。
 class Person
 {
 public:
     void show();
 private:
     char* _name;
     int _age;
 };
 void Person::show(){
         cout<<_name<<" "<<_age<<endl;
 }

2.2 创建对象:实例化一个类的对象。

下面代码指出stock是这个类的类型名。该声明让我们能够声明stock类型的变量,称为对象或实例,每个对象都表示一支股票。

 class stock
 {
 public:
     void acquire(const string& co,long n,double pr);
     void buy(long num,double price);
     void sell(long num,double price);
     void update(double price);
     void show();
 private:
     string company;
     long shares;
     double share_val;
     double total_val;
     void set_tot(){ total_val = share_val * shares;}
 };

我们使用下面的两个声明创建stock对象,它们分别名为sally和 solly:

 stock sally;    // sally对象表示Sally持有的某公司的股票。
 stock solly;

接下来要存储的数据以类数据成员(如company和shares)的形式出现。例如 sally 的 company 成员存储了公司名称,share 成员存储了 Sally 持有的股票数量,share_val 成员存储了每股的价格total_val 成员存储了股票总价格。同样,要执行的操作以类函数成员(方法,如 sell()和 update())的形式出现。成员函数可以就地定义(如 set_tot( )),也可以用原型表示(如其他成员函数)。

将数据和方法组合成一个单元是类最吸引人的特性。有了这种设计,创建 stock 对象时,将自动制定使用对象的规则。

类设计尽可能将公有接口与实现细节分开。公有接口表示设计的抽象组件。将实现细节放在一起并将它们与抽象分开被称为封装。数据隐藏(将数据放在类的私有部分中)是一种封装,将实现的细节隐藏在私有部分中,就像该类对 set_tot()所做的那样,也是一种封装。封装的另一个例子是,将类函数定义和类声明放在不同的文件中。

每个类定义了唯一的类型。对于两个类来说,即使它们的成员完全一样,这两个类也是两个不同的类型。例如:

 class First{
     int memi;
     int getMem();
 };
 class Second(){
     int memi;
     int getMem();    
 }; 
 First f;
 Second s=f;//不合法,s与f的类型不同。

即使两个类的成员列表完全一致,它们也是不同的类型。对于一个类来说,它的成员和其他任何类(或者任何其他作用域)的成员都不是一回事儿。

类的声明与函数类似,可以声明与定义分离,我们可以仅声明类而暂时不定义它:class Screen;

对于一个类来说,在我们创建它的对象之前该类必须被定义过,而不能仅仅被声明。否则,编译器就无法了解这样的对象需要多少存储空间。类似的,类也必须首先被定义,然后才能用引用或者指针访问其成员。毕竟,如果类尚未定义,编译器也就不清楚该类到底有哪些成员。

2.3对象的生命周期:构造函数和析构函数。

对于上文中的 stock 类,我们还应该为其提供被称为构造函数和析构函数的标准函数。下面我们来讨论为什么需要这些函数以及如何使用这些函数。这些函数也是类的成员函数,请读者结合后文进行阅读。

a. 构造函数

C++的目标之一就是让使用类对象就像使用标准类型一样。然而,到现在位置,本文提供的代码并不能让我们像初始化 int 或结构来初始化 stock 对象。也就是说,常规的初始化语法并不使用于类型 stock。

 stock hot = {"tencent",200 ,50.25};

此种方法并不能用于初始化 stock 对象。stock中数据部分的访问状态是私有的。这意味着程序不能直接地访问数据成员。但是我们已经知道程序可以通过成员函数来访问数据成员,因此就需要合适的成员函数,才能成功初始化对象。(如果使数据成员成为公有,而不是私有,就可以按刚才介绍的方法初始化类对但使数据成为公有的违背了类的一个主要初衷:数据隐藏)一般来看,我们最好最创建对象时对它进行初始化。例如:

 stock gift;
 gift.buy(10, 24.42);

就 Stock 类当前的实现而言,gift 对象的 company 成员是没有值的。类设计假设用户在调用任何其他成员函数之前调用 acquire(),但无法强加这种假设。避开这种问题的方法之一是在创建对象时,自动对它进行初始化。为此,C++提供了一个特殊的成员函数:类构造函数(constructor),专门用于构造新对象、将值赋给它们的数据成员。更准确地说,C++为这些成员函数提供了名称和使用语法,而程序员需要提供方法定义。名称与类名相同。例如,stock 类一个可能的构造函数是名为 stock()的成员函数。构造函数的原型和函数头有一个不一样的特征,即没有返回值,也没有被声明为 void类型。实际上,构造函数没有声明类型。

那么我们如何初始化stock对象?如何写 stock类的构造函数呢?

现在需要创建 Stock 的构造函数。由于需要为 Stock 对象提供3 个值,因此应为构造函数提供3 个参数(第4个值total_val 成员,是根据 shares 和 share_val 计算得到的,因此不必为构造函数提供这个值。程序员可能只想设置 company 成员,而将其他值设置为0,这可以使用默认参数来完成 因此原型如下所示:

 //constructor prototype with some default arguments
 stock(const string & co,long n =0,double pr = 0.0);

第一个参数是指向字符串的指针该字符串用于初始化成员company。n 和 pr参数为shares 和 share_val成员提供值。注意,没有返回类型。原型位于类声明的公有部分。 下面是构造函数的一种可能定义:

 // constructor definition
 Stock;:Stock(const string & co,long n, double pr)
 {
     company = co;
     if(n<0){    
         std::cerr <<"Number of shares can't be neqative;<< company << # shares set to 0.\n";
         shares = 0;
      }   
     else{
         shares = n;        
     }
     share val = pr;
     set_tot();
 }

上述代码和本章前面的函数 acquire()相同。区别在于,程序声明对象时,将自动调用构造函数。

构造函数的使用一般有两种方式。第一种是显式地调用构造函数: stock food = stock("World Cabbage",250,1.25); 。另一种是隐式地调用:stock food("World Cabbage",250,1.25);

但无法使用对象来调用构造函数,因为在构造函数构造出对象之前,对象是不存在的。因此构造函数被用来创建对象,而不能通过对象来调用。

除了上文中的有参构造函数,C++中含义默认构造函数。它是在未提供显式初始值时,用来创建对象的构造函数。也就是说,它是用于下面这种声明的构造函数:stock cat;

这条语管用的原因在于,如果没有提供任何构造函数,则C++将自动提供默认构造函数。它是默认构造函数的隐式版本,不做任何工作。对于 Stock 类来说,默认构造函数可能如下:

 stock::stock(){}

因此将创建 cat 对象,但不初始化其成员,这和下面的语句创建 ,但没有提供值给它一样int x; 默认构造函数没有参数,因为声明中不包含值。

奇怪的是,当且仅当没有定义任何构造函数时,编译器才会提供默认构造函数。为类定义了构造函数后程序员就必须为它提供默认构造函数。如果提供了非默认构造函数(如 stock(const char * co,int n, double pr);,但没有提供默认构造函数,则下面的声明将出错:stock cat;。这样做的原因可能是想禁止创建未初始化的对象。然而,如果要创建对象,而不显式地初始化,则必须定义一个不接受任何参数的默认构造函数。

定义默认构造函数的方式有两种。 一种是给已有构造函数的所有参数提供默认值。 另一种是通过函数重载来定义另一个构造函数,即一个没有参数的构造函数。

实际上,我们通常使用构造函数初始化列表(constructor initialize list)来给出我们定义的默认构造函数,通常给所有成员提供隐式初始化值。例如:

 stock::stock()
     :company("no name")
     , shares(0)
     , share_val(0)
     , total_val(0)
 {}    
 // 初始化列表的顺序并不是程序初始化对象时的顺序,而是按照声明变量的顺序来对变量进行初始化

无论何时只要类的对象被创建,就会执行构造函数。不同于其他成员函数,构造函数不能被声明为 const。当我们创建一个类的 const 对象时,直到构造函数完成初始化过程,对象才能获得其“常量”属性。因此,构造函数在 const 对象的构造过程中可以向其写值。

 stock() = default;

首先请明确一点:因为该构造函数不接受任何实参,所以它是一个默认构造函数。我们定义这个构造函数的目的仅仅是因为我们既需要其他形式的构造函数,也需要默认的构造函数。我们希望这个函数的作用完全等同于之前使用的合成默认构造函数。

在 C++11 新标准中,如果我们需要默认的行为,那么可以通过在参数列表后面写上=default 来要求编译器生成构造函数。其中,= default 既可以和声明一起出现在类的内部,也可以作为定义出现在类的外部。和其他函数一样,如果= default 在类的内部,则默认构造函数是内联的:如果它在类的外部,则该成员默认情况下不是内联的。

如果成员是const、引用,或者属于某种未提供默认构造函数的类类型,我们必须通过构造河南省初始化列表为这写成员提供初值。


b. 析构函数

使用构造函数创建对象后,程序负责追踪该对象,直到其过期为止。对象过期时,程序将自动调用一个特殊的成员函数——析构函数(destructor)。析构函数完成清理工作。例如,如果构造函数使用 new 来分配内存,则析构函数将使用 delete来释放这些内存。stock 的构造函数没有使用 new,因此析构函数实际上没有需要完成的任务。在这种情况下,只需让编译器生成一个什么要不故的隐式析构函数即可。下面为 stock 类提供一个析构函数。

和构造函数一样,析构函数的名称也很特殊,在类名前加上”~“。因此,stock 类的析构函数为~stock()另外,和构造函数一样,析构函数也可以没有返回值和声明类型。与构造函数不同的是,析构函数没有参数,因此Stock 析构函数的原型必须是这样的:

 ~stock();//类内
 stock::~stock();//类外

我们通常不应在代码中显式地调用析构函数。如果创建的是静态存储类对象,则其析构函数将在程序结束时自动被调用。如果创建的是自动存储类对象,则其析构函数将在程序执行完自动被调用。如果对象是通过 new 创建的,则它将驻留在栈内存或自由存储区中,当使用delete 来释放内存时,其析构函数将自动被调用。最后,程序可以创建临时对象来完成特定的操作,在这种情况下,程序将在结束对该对象的使用时自动调用其析构函数。总的来说就是,对象的生命周期结束时,C++编译系统自动调用析构函数。

注意:析构函数不能重载!

由于在类对象过期时析构函数将自动被调用,因此必须有一个析构函数。如果程序员没有提供析构函数,编译器将隐式地声明一个默认析构函数,并在发现导致对象被删除的代码后,提供默认析构函数的定义。

c.小结

构造函数是一种特殊的类成员函数,在创建类对象时被调用。构造函数的名称和类名相同,但通过函数重载,可以创建多个同名的构造函数,条件是每个函数的特征标(参数列表)都不同。另外,构造函数没有声明类型。通常,构造函数用于初始化类对象的成员,初始化应与构造函数的参数列表匹配。

就像对象被创建时程序将调用构造函数一样,当对象被删除时,程序将调用析构函数。每个类都只能有一个析构函数。析构函数没有返回类型(连 void 都没有)也没有参数,其名称为类名称前加上“~”。


三、成员变量和成员函数

3.1、成员变量:类的静态成员

a. 声明静态成员

类的普通成员变量我们不再进行赘述,我相信通过上文的描述,大家已经能理解其含义。我们在此处对类的静态成员进行解读。

有时候类需要它的一些成员与类本身直接相关,而不是与类的各个对象保持关联。例如,一个银行账户类可能需要一个数据成员来表示当前的基准利率。在此例中,我们希望利率与类关联,而非与类的每个对象关联。从实现效率的角度来看,没必要每个对象都存储利率信息。而且更加重要的是,一旦利率浮动,我们希望所有的对象都能使用新值。

我们通过在成员的声明之前加上关键字 static 使得其与类关联在一起。和其他成员一样,静态成员可以是 public 的或private 的。静态数据成员的类型可以是常量、引用、指针、类类型等。举一个例子,我们定义一个类,用它表示银行的账户记录:

 class Account {
 public:
     void calculate(){amount += amount* interestRate; }
     static double rate() { return interestRate; }
     static void rate(double);
 private:
     string owner;
     double amount;
     static double interestRate;
     static double initRate();
 };

类的静态成员存在于任何对象之外,对象中不包含任何与静态数据成员有关的数据。因此,每个Account对象将包含两个数据成员:owner和amount。只存在一个 interestRate 对象而且它被所有Account 对象共享。 类似的,静态成员函数也不与任何对象绑定在一起,它们不包含 this 指针。作为结果,静态成员函数不能声明成 const 的,而且我们也不能在 static 函数体内使用 this指针。这一限制既适用于this的显示使用,也对调用非静态成员的隐式使用有效。

我们使用作用域运算符直接就能访问静态成员:double r ; r=Accout::rate();

虽然静态成员不属于类的某个对象,但我们仍可以使用类的对象、引用、指针等来访问静态成员:

 Account ac1;
 Account* ac2 = &ac1;
 r= ac1.rate();
 r= ac2->rate();

成员函数不用通过作用域运算符就能直接使用静态成员。

b. 定义静态成员

和其他成员函数相同,我们既可以在类内也可以在类外定义静态成员函数。挡在类外定义静态成员是,不能重复 static 关键字,该关键字只出现在类内部的声明语句:

 void Account::rate(double newRate)
 {
     interestRate = newRate;
 }

和类的所有成员一样,当我们指向类外的静态成员时,必须指明成员所属的类名。static则只出现在类内部的声明语句中。

因为静态数据成员不属于类的任何一个对象,所以它们并不是在创建类的对象时被定义的。这意味着它们不是由类的构造函数初始化的。而且一般来说,我们不能在类的内部初始化静态成员。相反的,必须在类的外部定义和初始化每个静态成员。和其他对象一样,每个静态数据成员只能定义一次。 类似于全局变量,静态数据成员定义在任何函数之外。因此一旦它被定义,就将一直存在于程序的整个生命周期中。 我们定义静态数据成员的方式和在类的外部定义成员函数差不多。我们需要指定对象的类型名,然后是类名、作用域运算符已经成员的名字:double Account::interestRate = initRate();

这条语句定义了名为 interestRate 的对象,该对象是类Account 的静态成员,其类型是 double。从类名开始,这条定义语句的剩余部分就都位于类的作用域之内了。因此,我们可以直接使用 initRate 函数。注意,虽然 initRate 是私有的,我们也能用它初始化interestRate。和其他成员的定义一样,interestRate 的定义也可以访问类的私有成员。

3.2、成员函数:成员函数的定义和使用

成员函数(member function)定义与常规函数定义非常相似,它们有函数头和函数体,也可以有返回类型和参数。但是它们还有两个特殊的特征:

  • 定义成员函数时,使用作用域解析运算符(::)来标识函数所属的类。

  • 类方法可以访问类的 private 成员。

首先,成员函数的函数头使用作用域运算符解析(::)来指出函数所属的类。例如之前的类Person中show()函数的定义:

 void Person::show();

这种表示法意味着定义的 show() 函数是Person类的成员,这不仅将 show() 标识为成员函数还意味着我们可以将另一个类的成员函数也命名为 show() 。

其定义位于类声明中的函数都将自动成为内联函数。类声明通常将短小的成员函数作为内联函数。当然,也可以在类声明之外定义成员函数,并使其成为内联函数。只需要在类实现部分中定义函数时使用 inline 限定符即可。如:inline void Person::show();

所创建的每个新对象都有自己的存储空间,用于存储其内部变量和类成员。但同一个类的所有对象共享同一组类方法,即每种方法只有一个副本。例如,假设 Andy 和 Alice 都是 Person 对象,则 Andy.show() 将占据一个内存块,而 Alice.show() 占用另一个内存块。但两者都调用同一个方法,也就是说,他们执行同一个代码块,只是将这些代码用于不同的数据。在 OOP 中,调用成员函数被称为发送消息,因此将同样的消息发送给两个不同的对象将调用同一个方法,但该方法被用于两个不同的对象。

通常我们通过使用点运算符(.)来表达对成员函数的调用。如 Andy.show();。点运算符只能用于类类型的对象。其左侧运算对象必须是一个类类型的对象,右侧运算对象必须是该类型的一个成员名,运算结果为右侧运算对象指定的成员。

3.3、访问控制:public、private和protected关键字的使用

关键字private、protected和public描述了对类成员的访问控制。使用类对象的程序都可以直接访问公有部分,但只能通过公有成员函数(或友元函数)来访问对象的私有成员。因此,公有成员函数是程序和对象的私有成员之间的桥梁,提供了对象和程序之间的接口。防止了程序直接访问数据。

无论类成员是数据成员还是成员函数,都可以在类的公有部分或私有部分中声明它。但由于隐藏数据是OOP 主要的目标之一,因此数据项通常放在私有部分,组成类接口的成员函数放在公有部分。否则,就无法从程序中调用这些函数。正如 stock 声明所表明的也可以把成员函数放在私有部分中。不能直接从程序中调用这种函数,但公有方法却可以使用它们。通常,程序员使用私有成员函数来处理不属于公有接口的实现细节。

注:不必在类声明中使用关键字private,因为这是类对象的默认访问控制。即 class中默认的访问权限是private,struct中默认为public。使用class与struct的定义类的唯一区别就是默认的访问权限。

 class World
 {
     float mass;
     char name[20];
 public:
     void tellall();
     //...
 };

然而,为了程序的可读性,强调数据隐藏的概念,我们尽量显式地使用private。

  • 定义在public说明符之后的成员在整个程序内可以被访问,public成员定义类的接口。

  • 定义在private说明符之后的成员可以被类的成员函数访问,但是不能被使用该类的代码访问,private部分封装了类的实现细节。

派生类可以继承定义在基类中的成员,但是派生类的成员函数不一定有权访问从基类继承而来的成员。和其他使用基类的代码一样,派生类能访问公有成员,而不能访问私有成员。不过在某些时候基类中还有这样一种成员,基类希望它的派生类有权访问该成员,同时禁止其他用户访问。我们用受保护的 (protected)访问运算符说明这样的成员。

数据隐藏不仅可以防止直接访问数据,还让开发者(类的用户)无需了解数据是如何被表示的。例如,show()成员将显示某人的信息,这个值可以存储在对象中。从使用类的角度看,使用哪种方法没有什么区别。所需要知道的只是各种成员函数的功能。也就是说,需要知道成员函数接受什么样的参数以及返回作么类型的值。原则是将实现细节从接口设计中分离出来。如果以后找到了更好的、实现数据表示或成员函数细节的方法,可以对这些细节进行修改,而无需修改程序接口,这使程序维护起来更容易。

四、类的其他特性

4.1 对象中的this指针

C++中使用被称为 this 的特殊指针,来指向成员函数的对象(this被作为隐藏的参数传递给方法)。每个成员函数(包括构造和析构函数)都有一个this指针。this指针指向调用对象。如果方法需要引用整个调用对象,则可以使用表达式 *this。在函数的括号后面使用 const 限定符可以将this视为 const 。这样就不能通过 this 来修改对象的值。this是对象的地址。在此我们还使用上文的stock类来叙述:

 const stock& stock::topval(const stock& s)const
 {
     if(s.total_val > total_val)
         return s;
     else 
         return *this;
 }

返回引用意味着返回的是调用者本身,而不是其副本。假设我们要对 stock 对象 s1和s2进行比较,而隐式地访问 s2 。无论哪一种方式,都将两对象进行比较,并返回股价总值较高的那一个。

其中,s.total_val 是作为参数传递的对象的总值,total_val是用来调用该方法的对象的总值。如果stotal_val大于toatl_val,则函数将返回指向s的引用。否则,将返回用来调用该方法的对象。问题在于如何称呼这对象?如果调用s1.total_val(s2),则 s 是 s2的引用,但s1没有别名。

C++解决这种问题的方法是使用被称为 this 的特殊指针。this指针指向用来调用成员函数的对象(this被作为隐藏参数传递给方法)。这样函数调用s1.total_val(s2)将 this 设置为 s1 对象的地址使得这个指针可用于topval()方法。一般来说,所有的类方法都将 this 指针设置为调用它的对象的地址。确实topva()中的total_val 只不过是this->total_val 的简写。

  • this指针的类型:类类型 *const。只能在“成员函数”的内部使用。但如果在函数的括号后面使用const 限定符可以将 this限定为const,这样就不能使用this 来修改对象的值。

  • this指针本质上其实是一个成员函数的形参,是对象调用成员函数时,将对象地址作为实参传递给this形参。所以对象中不存储this指针。

  • .this指针是成员函数第一个隐含的指针形参,一般情况由编译器通过 ecx 寄存器自动传递,不需要用户传递。

4.2 拷贝与赋值:深拷贝与浅拷贝

如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造函数:

 class Foo{
 public:
     Foo();              //默认构造
     Foo(const Foo&);    //拷贝构造
 }

拷贝构造函数的第一个参数必须是引用类型。虽然我们可以定义一个接收非 const引用的拷贝构造函数。拷贝构造一般都会被隐式地使用,因此我们定义的拷贝构造函数通常不应该是 explicit的。

如果我们没有为一个类定义拷贝构造函数,编译器会为我们定义一个。与合成默认构造函数不同,即使我们定义了其他构造函数,编译器也会为我们生成一个拷贝构造函数。

每个成员的类型决定了它如何拷贝:对类类型的成员,会使用其拷贝构造函数来拷贝;内置类型的成员则直接拷贝。

拷贝构造函数是构造函数的一个重载形式。且它的参数只有一个且必须是类类型对象的引用,使用传值方式编译器直接报错。在函数调用过程中,具有非引用类型的参数要进行拷贝初始化。类似的,当一个函数具有非引用的返回类型时,返回值会被用来初始化调用方的结果。 拷贝构造函数被用来初始化非引用类类型参数,这一特性解释了为什么拷贝构造函数自己的参数必须是引用类型。如果其参数不是引用类型,则调用永远也不会成功一一为了调用拷贝构造函数,我们必须拷贝它的实参,但为了拷贝实参,我们又需要调用拷贝构造函数,如此无限循环。

 class Sales_data{
     Sales_data(const Sales_data&)
     {
         //...
     }
 }

注意:类中如果没有涉及资源申请时,拷贝构造函数是否写都可以;一旦涉及到资源申请时,则拷贝构造函数是一定要写的,否则就是浅拷贝。

若未显式定义,编译器会生成默认的拷贝构造函数。 默认的拷贝构造函数对象按内存存储按字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝。

我们要理解直接初始化和拷贝初始化之间的差异:

 string dog(10, ' ');                //直接初始化
 string s(dog);                      //直接初始化
 string s2 = dog;                    //拷贝初始化
 string book = "0-00-000";           //拷贝初始化
 string nines = string(100, '9');    //拷贝初始化

当使用直接初始化时,我们实际上是要求编译器使用普通的函数匹配来选择与我们提供的参数最匹配的构造函数。当我们使用拷贝初始化 (copyinitialization)时,我们要求编译器将右侧运算对象拷贝到正在创建的对象中,如果需要的话还要进行类型转换。拷贝初始化通常使用拷贝构造函数来完成。 拷贝初始化不仅在我们用=定义变量时会发生,在下列情况下也会发生。

  • 将一个对象作为实参传递给一个非引用类型的形参。

  • 从一个返回类型为非引用类型的函数返回一个对象。

某些类类型还会对它们所分配的对象使用拷贝初始化。与类控制其对象如何初始化一样,类也可以控制其对象如何赋值:

 Sales_data trans, accum;
 trans = accum;  //使用了Sales_data的拷贝赋值运算符

它与拷贝构造函数一样,如果类未定义自己的拷贝赋值运算符,编译器会为它合成一个。

在介绍合成赋值运算符之前,我们需要了解一点儿有关重载运算符 (overloaded operator)的知识:

重载运算符本质上是函数,其名字由 operator 关键字后接表示要定义的运算符的符号组成。因此,赋值运算符就是一个名为 operator=的函数。类似于任何其他函数,运算符函数也有一个返回类型和一个参数列表。 重载运算符的参数表示运算符的运算对象。某些运算符,包括赋值运算符,必须定义为成员函数。如果一个运算符是一个成员函数,其左侧运算对象就绑定到隐式的 this参数。对于一个二元运算符,例如赋值运算符,其右侧运算对象作为显式参数传递。

拷贝赋值运算符接受一个与其所在类相同类型的参数:

 class Foo{
     Foo& operator=(const Foo&)
     {
         //...
     }
 }

为了与内置类型赋值保持一致,赋值运算符通常返回一个指向其左侧运算对象的引用。标准库通常要求保存在容器中的类型要具有赋值运算符,且返回值是左侧运算对象的引用。

4.3 友元关系

C++控制对类对象私有部分的访问。通常,公有类方法提供唯一的访问途径,但是有时候这种限制太严格,以至于不适合特定的编程问题。这种情况下,C++提供了另外一种形式的访问权限:友元(friend)。友元有三种:

  • 友元函数

  • 友元类

  • 友元成员函数

通过让函数成为类的友元,可以赋予该函数与类的成员函数相同的访问权限。

注意:友元不存在传递性。即每个类负责控制自己的友元类或友元函数。

a. 友元函数

如果想把一个函数作为它的友元,只需要增加一条以friend关键字开头的函数声明语句即可。 为了介绍友元,我们引入一个 Sales_data类:

 class Sales_data {
     friend Sales_data add(const Sales_data&, const Sales_data&);
     friend istream& read(istream&, const Sales_data&);
     friend ostream& print(ostream&, const Sales_data&);
 public:
     Sales_data() = default;
     //...
     void show();
 private:
     string bookNo;
     size_t units_sold;
     double revenue;
 };
 //Sales_data接口的非成员组成部分声明。
 Sales_data add(const Sales_data&, const Sales_data&);
 istream& read(istream&, const Sales_data&);
 ostream& print(ostream&, const Sales_data&);

友元声明只能出现在类定义的内部,但是在类内出现的具体位置不限。友元不是类的成员也不受它所在区域访问控制级别的约束。因此,一般来说,最好在类定义开始或结束前的位置集中声明友元。

友元的声明仅仅指定了访问的权限,而非一个通常意义上的函数声明。如果我们希望类的用户能够调用某个友元函数,那么我们就必须在友元声明之外再专门对函数进行一次声明。 为了使友元对类的用户可见,我们通常把友元的声明与类本身放置在同一个头文件中(类的外部)。因此,我们的 Sales_data 头文件应该为 read、print 和add 提供独立的声明(除了类内部的友元声明之外)。

我们常用的友元是重载 << 运算符。使之可以与 cout 一起来显示对象的内容。 我们上文为了打印 Sales_data 对象中的数据,我们定义了 show(),但是重载了 << 后,假设 d是Sales_data对象,我们可以直接这样做:cout << d;

如果我们 operator<<直接使用Sales_data成员函数来重载 << ,那么Sales_data对象将是第一个操作数。这意味着我们只有 Sales_data << cout;。但是如果通过使用友元函数,可以像下面这样重载运算符:

 ostream& operator<<(ostream &os, const Sales_data& t)
 {
     os<< t.bookNo << units_sold << revenue;
     return os;
 }

这样可以使用如下语句 cout << Sales_data; 。如上重载使用 ostream 引用os 作为它的第一个参数。通常情况下,os 引用 cout 对象,如表达式cout <<Sales_data所示。但也可以将这个运算符用于其他ostream 对象,在这种情况下,os将引用相应的对象。

只有在类声明的原型中才能使用 friend 关键字。除非函数定义也是原型,否则不能再函数定义中使用该关键字。

b. 友元类和友元成员函数

类并非只能拥有友元函数,也可以将类作为友元。在这种情况下,友元类的所有方法都可以访问原始类的私有成员和保护成员。另外,也可以做更严格的限制,只将特定的成员函数指定为另一个类的友元。哪些函数、成员函数或类为友元是由类定义的,而不能从外部强加友情。因此,尽管友元被授予从外部访问类的私有部分的权限,但它们并不与面向对象的编程思想相悖。相反,它们提高了公有接口的灵活性。

什么时候希望一个类成为另一个类的友元呢?我们来看一个例子。假定需要编写一个模拟电视机和遥控器的简单程序。决定定义一个Tv 类和一个 Remote 类,来分别表示电视机和遥控器。很明显,这两个类之间应当存在某种关系,但是什么样的关系呢?遥控器并非电视机,反之亦然,所以公有继承的 is-a 关系并不适用。遥控器也非电视机的一部分,反之亦然,因此包含或私有继承和保护继承的 has-a 关系也不适用。事实上,遥控器可以改变电视机的状态,这表明应将 Romote 类作为 Tv类的一个友元(is-a和has-a 我会在后续介绍 继承多态的文章中进行叙述。)。如下:

 class Tv {
 public:
     friend class Remote; //Remote can access Tv private parts
     enum { Off, On };
     enum { Antenna, Cable };
     Tv(int s = Off, int mc = 125)
         :state(s), mode(Cable)
     {}
     void onoff() { state = (state == On) ? Off : On; }
     bool ison() const { return state == On; }
     void set_mode() { mode = (mode == Antenna) ? Cable : Antenna; }
     void settings() const; // display all settingsprivate:
 private:
     int state;          // on or off
     int mode;           //  brodcast or cable
 };
 class Remote {
 public:
     Remote(int m = Tv::TV) : mode(m) {} 
     void set_chan(Tv& t, int c) { t.channel = c; }
     void set_mode(Tv& t) { t.set_mode(); }
 private:
     int mode;
 };

从上述可以看出所有Remote 方法都是 Tv类的友元。即 Remote对象可以访问 Tv对象的任何访问权限的成员。Remote的所有方法都可以影响Tv类的私有成员。

从上一个例子中的代码可知,大多数 Remote 方法都是用 TV类的公有接口实现的。这意味着这些方法不是真正需要作为友元。事实上,唯一直接访问 Tv 成员的 Remote 方法是 Remote::set_chan( ),因此它是唯一需要作为友元的方法。确实可以选择仅让特定的类成员成为另一个类的友元,而不必让整个类成为友元。让 Remote:set_chan()成为 Tv类的友元的方法是,在Tv类声明中将其声明为友元(友元成员函数):

 class Tv{
     friend void Remote::set_chan(Tv& t, int c);
 };

  • 38
    点赞
  • 33
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 4
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

无敌岩雀

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值