2.类与对象

第二章 类与对象基础

面向对象思想

过程论认为:数据和逻辑是分离的、独立的,程序世界本质是过程,数据作为过程处理对象,逻辑作为过程的形式定义,世界就是各个过程不断进行的总体。

对象论认为:数据和逻辑不是分离的,而是相互依存的。相关的数据和逻辑形成个体,这些个体叫做对象,世界就是由一个个对象组成的。对象具有相对独立性,对外提供一定的服务。所谓世界的演进,是在某个“初始作用力”作用下,对象间通过相互调用而完成的交互;在没有初始作用力下,对象保持静止。这些交互并不是完全预定义的,不一定有严格的因果关系,对象间交互是“偶然的”,对象间联系是“暂时的”。世界就是由各色对象组成,然后在初始作用力下,对象间的交互完成了世界的演进。过程论和对象论不是一种你死我活的绝对对立,而是一种辩证统一的对立,两者相互渗透、在一定情况下可以相互转化,是一种“你中有我、我中有你”的对立。如果将对象论中的所有交互提取出来而撇开对象,就变成了过程论,而如果对过程论中的数据和逻辑分类封装并建立交互关系,就变成了对象论。

过程论相对确定,有利于明晰演进的方向,但当事物过于庞大繁杂,将很难理清思路。因为过程繁多、过程中又有子过程,容易将整个世界看成一个纷繁交错的过程网,让人无法看清。对象论相对不确定,但是因为以对象为基本元素,即使很庞大的事物,也可以很好地分离关注,在研究一个对象的交互时,只需要关系与其相关的少数几个对象,不用总是关注整个流程和世界,对象论更有助于分析规模较大的事物。但是,对象论也有困难。例如,如何划分对象才合理?对于同一个驱动力,为什么不同情况下参与对象和交互流程不一样?如何确定?其实,这些困难也正是面向对象技术中的困难。

类的定义

C++用类来描述对象,类是对现实世界中相似事物的抽象,比如同是“双轮车”的摩托车和自行车,有共同点,也有许多不同点。“车”类是对摩托车、自行车、汽车等相同点的提取与抽象。

类的定义分为两个部分:

  1. 数据,相当于现实世界中的属性,称为数据成员;

  2. 对数据的操作,相当于现实世界中的行为,称为成员函数

有些地方,会将类的数据成员和成员函数统称为类的成员

从程序设计的观点来说,类就是数据类型,是用户定义的数据类型,对象可以看成某个类的实例(某类的变量)。所以说类是对象的抽象,对象是类的实例。

由对象抽象出类

由类实例化出对象

C++中用关键字class来定义一个类,其基本形式如下:类的定义和声明

class MyClass{//类的定义
    //……
    void myFunc(){}  //成员函数
    int _a;          //数据成员
};//一定要有分号


//类也可以先声明,后完成定义
class MyClass2;//类的声明

class MyClass2{//类的定义
    //……
};//分号不能省略

访问修饰符

如下,我们定义好一个Computer的类,假设我们站在代工厂的视角,这个Computer类拥有两个属性——品牌与价格;两个行为——设置品牌与设置价格

class Computer {
	void setBrand(const char * brand)
	{
		strcpy(_brand, brand);
	}
    
	void setPrice(float price)
	{
		_price = price;
	}
    
	char _brand[20];
	float _price;
};

按之前的理解,现在我们自定义了一个新的类——Computer类,我们需要实例化出一个对象(特定的Computer),再通过这个对象来访问数据成员或调用成员函数,如下:

Computer pc;
pc.setPrice(10000); //error
pc._price; //error

结果发现都会报错,这是什么原因呢?事实上,class中的所有的成员都拥有自己的访问权限,分别可以用以下的三个访问修饰符进行修饰

public: //公有的访问权限,在类外可以通过对象直接访问公有成员

protected://保护的访问权限,派生类中可以访问,在类外不能通过对象直接访问(后面学)

private://私有的访问权限,在本类之外不能访问,比较敏感的数据设为private,类定义中可以访问。

注意:

  • 类定义中访问修饰符的管理范围从当前行到下一个访问修饰符或类定义结束;

  • class定义中如果在成员定义(或声明)之前没有任何访问修饰符,其默认的访问权限为私有

    补充:public的成员函数也可以称为接口,就是该类提供给外界使用的路径,在类外可以直接通过对象进行访问

    image-20240422114421958
class Computer {
public:
	void setBrand(const char * brand)
	{
		strcpy(_brand, brand);
	}
	void setPrice(float price)
	{
		_price = price;
	}
private:
	char _brand[20];
	float _price;
};
    
Computer pc;
pc.setPrice(10000); //ok
pc._price; //error,因为_price是私有的

struct与class的对比

学习了类的定义后,我们会发现它与C语言中的struct很相似。

  • C语言中的struct

回顾一下C语言中struct的写法

struct Student{
    int number;
    char name[25];
    int score;
};

void test0(){
    struct Student s1;
    struct Student s2 = {10,"Jack",98};
}

采用typedef取别名后更像C++的class

typedef struct{
    int number;
    char name[25];
    int score;
} Student;

void test0(){
    Student s1;
    Student s2;
}

C中的struct只能是一些变量的集合体,可以封装数据但不能隐藏数据,而且成员不能是函数,要使用函数只能使用函数指针的方式。访问权限限制、继承性、构造析构都没有。事实上,C中struct的这种封装属于广义上的封装。面向对象的封装是指隐藏对象的属性和实现细节,仅对外公开接口,控制在程序中属性的读和修改的访问级别;将抽象得到的数据和操作数据的方法相结合,形成“类”

  • C++中的struct

C++中的struct对C中的struct做了拓展,基本等同于class,默认访问权限是public.

  • C++中的class

class默认访问权限是private.

成员函数的定义

  • 成员函数定义的形式

成员函数可以在类内部完成定义,也可以在类内部只进行声明,在类外部完成定义。

class Computer {
public:
	//成员函数
	void setBrand(const char * brand);//设置品牌

	void setPrice(float price);//设置价格
        
    void print();//打印信息
private:
	//数据成员
	char _brand[20];
	float _price;
};

void Computer::setBrand(const char * brand)
{ 
    strcpy(_brand, brand); 
}
void Computer::setPrice(float price)
{ 
    _price = price;
}

实际开发中为什么采用成员函数声明和实现分离的写法?

当类中成员函数比较多(复杂),不容易看,如果只在类中进行成员函数的声明(同时配上注释),会方便理解。

image-20240422115835252
  • 多文件联合编译时可能出现的错误

为什么一般不在头文件中定义函数?

在头文件中定义一个函数时,如果多个源文件都包含了该头文件,那么在联合编译时会出现重定义错误。因为头文件的内容在每个源文件中都会被复制一份,而每个源文件都会生成对应的目标文件。在链接的阶段,会出现多个相同函数定义的情况,导致重定义错误。

对于成员函数,也存在这样的问题。

如果在头文件中采用成员函数声明和定义分离的形式,在类外部完成成员函数的实现,就会陷入这个错误。

解决方法1:在成员函数的定义前加上inline关键字,inline函数定义在头文件中是ok的

image-20240422144517522

解决方法2:将成员函数放到类内部进行定义(说明类内部定义的成员函数就是inline函数——默认效果)

image-20240422144651733

解决方法3:函数声明放在头文件,函数定义放在实现文件中,就算有多个测试文件,也不会出现重定义(最常用的方式)。

之后遇到这种需求(定义一个非常复杂的类,多处都需要用到这个类)

对象的创建

在之前的 Computer 类中,通过自定义的公共成员函数 setBrand 和 setPrice 实现了对数据成员的初始化(严格意义上是赋值)。

实际上,C++ 为类提供了一种特殊的成员函数——构造函数来完成真正的初始化。

  • 构造函数的作用:就是用来初始化数据成员的

  • 构造函数的形式:

    没有返回值,即使是void也不能有;

    函数名与类名相同,再加上函数参数列表。

构造函数在对象创建时自动调用,用以完成对象成员变量等的初始化及其他操作(如为指针成员动态申请内存等)

对象的创建规则

  1. 当类中没有显式定义构造函数时 ,编译器会自动生成一个默认 (无参) 构造函数 ,但并不会初始化数据成员;

    以Point类为例:

    class Point {
    public:
    	void print()
    	{
    		cout << "(" << _ix 
                << "," << _iy
    			<< ")" << endl;
    	}
    private:
    	int _ix;
    	int _iy;
    };
    
    void test0()
    {
    	Point pt; //调用了默认的无参构造
    	pt.print();
    }
    //运行结果显示,pt的_ix,_iy都是不确定的值
    

    Point pt; 这种方式创建的对象,其数据成员没有被初始化,输出的会是不确定的值

  2. 一旦当类中显式提供了构造函数时 ,编译器就不会再自动生成默认的构造函数;

    class Point {
    public:
        Point(){
            cout << "Point()" << endl;
            _ix = 0;
            _iy = 0;
        }
    	void print()
    	{
    		cout << "(" << _ix 
                << "," << _iy
    			<< ")" << endl;
    	}
    private:
    	int _ix;
    	int _iy;
    };
    
    void test0()
    {
    	Point pt;
    	pt.print();
    }
    //这次创建pt对象时就调用了自定义的构造函数,而非默认构造函数
    
  3. 编译器自动生成的默认构造函数是无参的,构造函数也可以接收参数,在对象创建时提供更大的自由度;

    class Point {
    public:
        Point(int ix, int iy){
            cout << "Point(int,int)" << endl;
            _ix = ix;
            _iy = iy;
        }
    	void print()
    	{
    		cout << "(" << _ix 
                << "," << _iy
    			<< ")" << endl;
    	}
    private:
    	int _ix;
    	int _iy;
    };
    
    void test0()
    {
    	Point pt;//error,没有默认的无参构造函数可供调用
        Point pt2(10,20);
    	pt2.print();
    }
    
  4. 如果还希望通过默认构造函数创建对象, 则必须要手动提供一个默认构造函数;

    class Point {
    public:
        Point(){}
        
        Point(int ix, int iy){
            cout << "Point(int,int)" << endl;
            _ix = ix;
            _iy = iy;
        }
    	void print()
    	{
    		cout << "(" << _ix 
                << "," << _iy
    			<< ")" << endl;
    	}
    private:
    	int _ix;
    	int _iy;
    };
    
    void test0()
    {
    	Point pt;//ok
        Point pt2(10,20);
    	pt2.print();
    }
    
  5. 构造函数可以重载

​ 如上,一个类中可以有多种形式的构造函数,说明构造函数可以重载。事实上,真实的开发中经常会给一个类定义各种形式的构造函数,以提升代码的灵活性(可以用多种不同的数据来创建出同一类的对象)。

image-20240516152155610

对象的数据成员初始化

上述例子中,在构造函数的函数体中对数据成员进行赋值,其实严格意义上不算初始化(而是算赋值)。

在C++中,对于类中数据成员的初始化,推荐使用初始化列表完成。

初始化列表位于构造函数形参列表之后,函数体之前,用冒号开始,如果有多个数据成员,再用逗号分隔,初始值放在一对小括号中。

class Point {
public:
	//...
	Point(int ix = 0, int iy = 0)
	: _ix(ix)
    , _iy(iy)
	{
		cout << "Point(int,int)" << endl;
	}
	//...
};

如果没有在构造函数的初始化列表中显式地初始化成员,则该成员将在构造函数体之前执行默认初始化。如在“对象的创建规则”示例代码中,有参的构造函数中 _ix 和 _iy 都是先执行默认初始化后,再在函数体中执行赋值操作。

image-20240516154906523

补充:数据成员的初始化并不取决于其在初始化列表中的顺序,而是取决于声明时的顺序(与声明顺序一致)

  • 构造函数的参数也可以按从右向左规则赋默认值,同样的,如果构造函数的声明和定义分开写,只用在声明或定义中的一处设置参数默认值,一般建议在声明中设置默认值。

    class Point {
    public:
    	Point(int ix, int iy = 0);//默认参数设置在声明时
    	//...
    };
    
    Point::Point(int ix, int iy)
    : _ix(ix)
    , _iy(iy)
    {
    	cout << "Point(int,int)" << endl;
    }
    
    void test0(){
        Point pt(10);
    }
    
  • C++11之后,普通的数据成员也可以在声明时就进行初始化。但一些特殊的数据成员初始化只能在初始化列表中进行,故一般情况下统一推荐在初始化列表中进行数据成员初始化。

class Point {
public:
	//...
    int _ix = 0;//C++11
    int _iy = 0;
};
  • 数据成员初始化的顺序与其声明的顺序保持一致,与它们在初始化列表中的顺序无关(但初始化列表一般保持与数据成员声明的顺序一致)。

对象所占空间大小

之前在讲引用的知识点时,我们提过使用引用作为函数的返回值可以避免多余的复制。内置类型的变量最大也就是long double,占16个字节。但是现在我们学习了类的定义,自定义类型对象的大小可以非常大。

使用sizeof查看一个类的大小和查看该类对象的大小,得到的结果是相同的(类是对象的模板);

void test0(){
    Point pt(1,2);
    cout << sizeof(Point) << endl;
    cout << sizeof(pt) << endl;
 }

成员函数并不影响对象的大小,对象的大小与数据成员有关(后面学习继承、多态,对象的内存布局会更复杂);

现阶段,在不考虑继承多态的情况下,我们做以下测试。发现有时一个类所占空间大小就是其数据成员类型所占大小之和,有时则不是,这就是因为有内存对齐的机制。


                
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值