0%

【教程】泛谈“面向对象”

不知不觉18年就过完了,转眼间又到了考试季,各位小伙伴们都复习的怎么样啊?
好吧今天我们不谈考试,就坐下来好好说说这个“面向对象”的概念,有些同学可能一学期下来也搞不清这个到底有什么用、怎么用。这次就结合Java,当然也不只有Java,来说说面向对象的语言特性,彻底缕清这个概念。

①基础

有些东西需要你在本次学习之前彻底搞清,以便下面的学习得更轻松,这里讲一下几个概念,如果会的话可以跳过。

㈠声明和实例化

有些同学读课本代码时可能经常发现这样一句话: XXX xxx=new XXX(); 大家都知道,这是创建对象的过程,其中XXX是类名,xxx是新创建的对象名,但是这具体是什么意思呢? 其实,这句话干了两件事,分别是: ①XXX xxx; ②xxx=new XXX(); 其中第一步是声明对象,第二步是实例化对象 通俗点讲,比如这是一个做泥塑的过程,声明对象就是向电脑要了一堆泥,而实例化就是把这堆泥捏成了泥塑。 那么,具体要怎么把泥捏成泥塑呢?这就需要调用类的构造函数了,所以说,第二步后面的XXX()与前面声明的类XXX并没有必然的联系,它只是个构造函数,在特定情况下也可以写其他类的构造函数,这个我们后面再讲。 有点晕?没关系我们看一个例子: 建立一个名为“Student”的类,类包含字段“班级”(theClass)和“学号”(schoolNum):

1
2
3
4
5
class Student
{
public String theClass;
public long schoolNum;
}

然后我们分别对其声明并实例化,然后调试: 可以看到,最开始没有student这个对象,我们对其声明后就有了这个对象,但是值为null,空,但是我们知道Student类是有结构的(两个字段),只是null的话连结构都没有。 然后我们继续运行,让它实例化。 实例化之后student的值就变成了{Student},我们打开变量监视器可以看到其中的结构已经就绪。 所以,如果你的编译器报了空指针错误(nullpointer),那多半就是没实例化导致的。这样是不是就多少明白些这两步的关系了? 总结:声明就是按照类中的结构大小分配存储空间,并没有真正地构造对象,只有使用了构造函数将其实例化,一个完整的对象框架才会被真正建立起来。

㈡构造函数

刚刚讲实例化的时候我们就接触过构造函数了,并且知道它的作用是构建对象框架。那么它和普通函数还有什么区别吗?是不是随便一个函数都可以作为构造函数呢? 首先我们要明确的就是,不管是什么类都默认有构造函数,只是有的类不能被实例化(比如抽象类),并且它是默认存在的,即你不定义它,它也会自动生成,只是除了实例化对象之外没有其他操作了而已。 然后,我们再讲一下它和普通函数的区别: 1. 它只能在对象被实例化的时候调用,且调用时必须加关键字new 2. 构造函数的名字必须与类名相同 3. 构造函数默认且必须返回值为空,因为这个规则,构造函数就可以省略返回类型而直接写函数名 稍微解释一下,以构造函数的用途思考这些规则其实都能想通,构造函数只为实例化对象而生,肯定只能在实例化的时候调用,并且肯定与类名相同,要不谁知道是哪个类啊?“实例化”本身就是个动作,这个动作注定它不能返回什么值,而是指使计算机去做出什么,所以肯定返回值是空。最后需要注意的是构造函数的访问修饰符一般是public,否则就无法在其他类里调用其实例化了。

②正篇

说了这么多终于进入正题了,下面我们就好好谈谈这个面向对象到底是什么,有什么好处,为什么这么多语言都用这个概念,具体该怎么用。

㈠对象是什么?

说白了,它就是个东西(object),你管它什么东西(object),它就是个东西(object)。 东西是什么?什么都有可能,只要数据能描述这个东西,那它就能成为对象。 注意我把这个单词object加粗了,这是英文原版的“对象”,如果不了解这个单词希望去查一下词典,看看其名词形式下都有什么释义,这样你就会对“对象”这个概念有所体会。 我觉得其实叫“面向东西”更能表达其精髓,但是这么翻译太难听了,还是面向对象吧。

㈡那么面向东西,呃不面向对象有什么用?

⑴条理化的数据操作

我们编程为了什么?简化用户操作?优化用户体验?这不能,都太表面了,这往白了说,就是处理数据! 处理什么数据?肯定不是一点数据,如果数据少那人算就可以了,根本用不着专门写个程序,所以我们处理的是大量的数据!至少量大到需要专门写个程序分析才行。 为了给大家一个客观的感受,放出来前几天扒的QQ音乐网页版搜索返回值: 以上是搜索了“测试”这两个字的返回结果,为了保证可读性我进行了一点优化,这里每一行都是一条数据对象,也就是一个音乐。。的东西,只不过不是Java的对象,是JavaScript的对象,每行长度在880字到1024字之间,其中每条数据对象中都有非常多的字段,要想顺利承接这些字段,我需要整理它的结构。通过分析,得到每条数据的结构如下: 其中每个大括号里面又是这个类的子类。可以看出,其中数据真的非常多,但是却十分条理,分门别类地存储在相应的字段中,找起来也十分方便。 返回我们之前的话题中,程序员需要写图一中的每一条数据吗?并不是,程序员写的只是图二中的结构(类),结构写完,数据就自动根据位置“穿”进去了,强迫症福利,是不是?

⑵模块化,易于维护

刚刚讲的数据操作其实用C语言的结构体也可以做到,但是C语言不是面向对象的,所以要说起面向对象真正的优势,还在于模块化上。 那么类比结构体多的不就是能在里面写函数吗?对,所以我们不需要把函数“随用随写”,而是把属于这个类的函数专门写在这个类里,用的时候直接调用,这就是模块化了。哪里好呢?比如我A类的函数写好,调用之发现运转正常,这个类就可以不管了,B类调用的时候出现问题那肯定是B类的故障而不是A类,一个模块就干一个事。为了让程序员方便知道一个对象中有哪些数据可以动,哪些不能动,还引入了封装的概念,这个我们在下面也会提到。 另外也为程序的拓展带来了方便,copy别人的代码更爽了(逃

⑶灵活多变,省时省力

通过继承一个类,我们可以快速得到很多有共同特性的派生类,还可以进行重写(override)区分派生类中的特性。

㈢那该怎么用?

好了,到这里就是我们这个文章的核心了,我的真正目标是教会大家明确对象这个概念,并且能用这个概念写出条理清楚、整洁的代码

⑴重中之重,先别急着写代码,想想咱要干嘛!

要求学生的平均分还是算正方形的面积?一共需要几个类?哪个类是负责统筹主逻辑的?哪个类是负责记载数据的?算平均分的话需要哪些数据?数据类型应该是什么?…… 这些都是必须要考虑的问题,特别是今后要写较大项目的时候,不管是和别人一起写还是自己独干,千万别图一时手快,否则写着写着突然发现结构不对进行不下去了,要全部重构的时候就等着哭咯!

⑵明确结构

这个结构不是指的上面说的具体结构,而是类的结构,总体来讲类的结构分为以下四个: 1. 字段 字段就是直接在类里面定义的变量,该变量可以在类里共享,是否与外界共享要看访问修饰符。 2. 访问器 访问器就是get/set,用来设置字段的,可以在一定程度上对读/取进行限制,以达到封装的目的,值得一提的是,访问器本质上就是个普通函数,只是我们为这类普通函数起了一个共有的名字而已,但是我们完全可以把它当作一个普通函数来记忆。 3. 方法 方法就是类中的函数。 4. 内部类 内部类不太常见,但也是类中的结构之一,注意内部类可以读取外部类的字段,也可以调用外部类的函数。

⑶封装

了解了结构,我们就开始封装类了。我们都知道创立了类、构建了结构肯定是要对数据进行存取,定义好哪些可以存,哪些可以取,这样不管是对别人还是对自己都能起到良好的导航效果,我们的目标就是只暴露需要的,不需要的统统隐藏。 那我们就需要了解访问修饰符了: 访问修饰符分为访问符修饰符,我们先从比较好说的访问符开始:

访问符,顾名思义,就是用来修饰访问性的,下面我们就来比较一下Java的四个访问符(没有也算一个)标识的可访问性:

同一个类

同一个包

不同包的子类

不同包的非子类

private

没有访问符

protected

public

然后我们再来讲讲修饰符,修饰符的目的就是再对被修饰的语句进行进一步的解释,赋予其更多功能:

修饰符

功能

abstract

抽象语句,用于类和方法的修饰,具体下节讲

final

表示“固定”、不可变,用在字段的定义中就代表常量,用在类的定义中就代表该类不可被继承

static

静态语句,可修饰类、字段、方法,具体下节讲

了解了访问修饰符我们才能真正开始封装,那么,具体应该怎样封装呢?下面有几种常用封装手段: 封装字段的手段:(默认以全部使用private封装)

封装手段

写入

读出

优势

劣势

访问符public或者不写访问符)

方便,只改修饰符就成

不受控制的修改,暴露程度太高

利用构造函数

×

实例化对象时即进行赋值,大大减少代码量

如不通过其他手段则写入的值无法读出

设置访问器

在变量被修改前可以进行筛选等高级控制

麻烦,需要专门另写方法

封装方法则一律使用修饰符。 可根据需要灵活选择解决方案,也可同时使用多种手段。

⑷掌握类之间的关系:继承与多态

这是最后一节了,学会了就胜利了,加油!

1.特殊情况:静态

在学习类之间的关系之前我们要先解决上一节遗留下来的一个问题:静态关系。 之所以不在上节讲是因为其与封装关系不大,但是与这一节的关系也不大,单独拿出来内容又太少,所以只好放在这里了,望见谅。 那么静态到底是什么? 我们知道类中的结构和方法是无法直接使用的,必须通过实例化为对象再使用,可是有些方法/字段我们只使用一次,或者说没有必要单独成为对象,我们就可以把它写为静态方法/字段,这样一来,在调用时不需要实例化,直接类名.方法/字段就可以使用它们了。 注意静态方法有个坑,就是静态方法只能引用静态方法,不能引用非静态方法,但是反过来——非静态方法可以引用所有方法 值得一提的是,类也可以被静态修饰符修饰,那样就是静态类,静态类必须是内部类。正常情况下我们必须实例化外部类才能进一步实例化内部类,但是静态类可以允许我们直接实例化某个内部类,当然与普通类相比还有其他微小的差别,若感兴趣可以去百度一下,这里就不展开讲了。

2.继承(相同)

我这里加了个括号,写了个相同,意思是继承的主要目标是操作类与类之间相同的地方,A类继承B类,将得到B类所有已定义的结构,包括字段、访问器、方法、内部类 而这一切,只需要你在定义的类名后加个extend即可。 我们定义了山羊类,可以很轻松地继承出绵羊类。当然你也可以从羊类继承出山羊类和绵羊类,但是两者差异不大,绵羊不就是多点毛么,直接从山羊类继承出来也未尝不可。

3.多态(差异)

多态我们主讲差异,刚刚我们讲了继承,讲了A类能从B类继承所有内容,但是如果我想突出A类与B类的不同呢? 比如,同是大学生,但是少林寺章丘分寺的大学生武术不及格就不能毕业,那么关于毕业这个算法,从大学生类继承出来的少林寺章丘分寺的大学生类肯定不能相同,为了实现这个功能,我们引入重写(override)的概念,注意这不是重构(overload)哈,别搞混了,忘了重构的自己去查,这里不讲了。 重写就是直接把要重写的函数在子类再写一遍,内容和父类不同就是了。这个很好理解,记得不仅方法可以重写,字段也可以重写。 好了重写讲完了,下面我们来讲讲所谓真正的多态: 还是刚刚的大学生类,但是我们给他多一个函数,让他说自己会不会打武术:

1
2
3
4
5
6
7
8
9
class Student
{
public string theClass;
public long schoolNum;
public void isMartial()
{
System.out.println("不会打武术");
}
}

然后我们再继承出一个少林寺章丘分寺的大学生类,重写会不会打武术的方法:

1
2
3
4
5
6
7
class QSStudent extends Student
{
public void isMartial()
{
System.out.println("作为少林寺章丘分寺的学生,像你这种问题,我就三个字!打!....扰了");
}
}

重点来了,我们这样调用:

1
2
Student student=new QSStudent();
student.isMartial();

对,你没看错,构造函数和前面声明的类不一样!请问会写出什么? 答案是:

作为少林寺章丘分寺的学生,像你这种问题,我就三个字!打!….扰了

然后我们再做一个实验:实例化一个Student数组,然后分别用Student的构造方法和QSStudent的构造方法为其中的元素实例化,看看有什么效果:

1
2
3
4
5
6
7
Student[] students=new Student[2];
students[0]=new Student();
studnets[1]=new QSStudent();
for(int i=0;i<students.length;i++)
{
students[i].isMartial();
}

这样可以编译通过吗?这可是Student类型的数组哦! 答案是可以的! 输出为

不会打武术 作为少林寺章丘分寺的学生,像你这种问题,我就三个字!打!….扰了

好的让我们停一下,捋一下思路:这两次实验正好得到了相反的结果:第一个实验说声明为父类型,却用子类型构造函数实例化,且实例化出的对象使用的是子类的函数,说明实例化出的对象可能是个子类型。第二个实验却表明只能容纳同一种数据类型的数组却好像容纳了两个数据类型。好的我们再看第三个实验: 直接输出student的类型:

1
2
Student student=new QSStudent();
System.out.println(student.getClass())

它输出的是

class QSStudent

这直接说明了用什么构造方法,就会出来什么类型的对象,声明的类型可以是其父类型 那么如果我们使用多态调用了子类型有但父类型没有的函数/字段会怎样呢?这是不可以的,会报错。 那么,这还能算是一个完整的子类型吗?当然不算,因为它本质上还是父类型!只不过这种父类型中所有的函数/字段都被引向了被构造函数实例化的子类型! 其实这才是真正意义上的“多态”,即父类型的不定性。 所以我们可以总结为: 当使用多态方式调用方法时,首先检查父类中是否有该方法,如果没有,则编译错误;如果有,再去调用子类的同名方法。 例二的数组用法是多态最多的表现形式,当然这只是多态的一点皮毛,如果想深入了解可以自行百度,这里仅介绍。

4.抽象类(集成)

我们刚刚了解了了继承(相同)、多态(差异),经由那两个的基础再升级我们就得到了抽象(集成)。这是个什么意思? 还记得继承那一节里的绵羊和山羊吗?当时我们说绵羊和山羊的差异不大,所以完全可以图方便从山羊类那里派生出一个绵羊类来,但是实际上山羊和绵羊并不是继承关系,而是并列关系,它们的父类应该都是羊类,如果我们偷懒从山羊类派生出绵羊类,简单使用起来好像没什么问题,但是如果我们想使用多态的知识,即把它们做成一个数组,绵羊就成了山羊,这样显然是不合逻辑的。但是如果我们写了一个羊类,又会有傻瓜队友把它不小心实例化了,这也会造成很大的麻烦,因为它是一个不具体的对象。为了更加明确其中的结构,我们造出一个专门用来集成的类、全职父类,就是抽象类了。 抽象类使用abstract修饰,正常类里面有的东西它也都可以有,只是不能被直接实例化了,另外抽象类中也可以存在未实现、即没有方法体的方法,这种方法叫抽象方法,也用abstract修饰,若一个类派生自抽象类,那么其中的抽象函数必须被实现,否则不能实例化。当然也可以把抽象函数就这么一级一级继承下去,但是除非在哪一级被实现,否则继承类都不能实例化。 这样从某种意义上讲抽象类就是为多态而服务的。

③总结:一个例子

下面我们用一个例子来简单回顾一下刚才所学的内容: 现在,我们写一个普通大学生类,类中有字段学院(academy)、班级(theClass)、学号(studentNum)和一个报告自己会不会武术的方法isMartial(),还有一个内部类“Grading”,用来保存学生的成绩,包含专业课、英语和政治三科:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//普通高校学生
class Student
{
public String academy;
public String theClass;
public Grading grading;
public long studentNum;
public void isMartial()
{
System.out.println("不会打武术");
}

//普通高校成绩
public class Grading
{
public int specializedCourse;
public int english;
public int politics;
}
}

但是少林寺章丘分寺的学生不一样啊!他不会武术毕不了业,所以在其内部类中应添加一个武术成绩,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//少林寺章丘分寺的学生
class QSStudent extends Student
{
public QSGrading grading;
public void isMartial()
{
System.out.println("作为少林寺章丘分寺的学生,像你这种问题,我就三个字!打!....扰了");
}

//少林寺章丘分寺的成绩
class QSGrading extends Student.Grading//注意这里的内部类继承写法
{
public int martial_Art;
}
}

然后我们在main函数中分别实例化,然后为其英语成绩赋值:

1
2
3
4
5
6
7
8
9
10
public static void main(String[] args)
{
Student student1=new Student();
Student student2=new QSStudent();
QSStudent student3=new QSStudent();

student1.grading.english=90;
student2.grading.english=90;
student3.grading.english=90;
}

想一想,这样写能编译通过吗?如果不能,应该怎样改呢?(答案在结尾)

④(后记)目前以来遇到的坑

想了想从信心满满地写了第一个小程序(小时钟)以来已经过去半年了,中间也陆陆续续开了很多坑,更踩了不少雷,从这里总结一下,为大家做一次侦察兵,希望大家在未来的编程工作中少走弯路。

⑴尽量不要在构造函数、main函数以及各种控件的事件处理方法中写很多逻辑!

注意我这里说的是逻辑而不是函数,函数可以有,但是选择、循环等逻辑能少则少,那逻辑挪到哪去?当然是另建一个专门处理逻辑的函数,然后在这些函数中引用逻辑函数。 比如要制作GUI,很多人一上来就在main函数里duang duang duang把按钮、布局等等控件全初始化,甚至在main函数里给控件加匿名处理方法,搞得全篇就main函数最长,好家伙近一百行。这是万万使不得的! 正确的处理方式是,写一个初始化控件的函数,再写一个专门给布局加控件的函数,然后在main函数里引用这两个函数。这个在行业里专门有个称呼,叫“降低耦合度”,不单是这里,其实所有函数都要尽量减少其功能,让一个函数只干一件事,各个功能彼此分离,然后利用其互相调用完成想要的功能。不要一次制作一整个机械巨人,反而要像钟表匠一样把小齿轮彼此精密地连接在一起。以后应用接口的时候更需要这样。 为什么呢? 第一是便于debug,就像上面讲的面向对象原理一样,功能分离就可以把确定无误的功能和模棱两可的功能分离开,出现问题主要去检查容易出错的函数就可以。 另一个,也就是最重要的一个原因就是降低代码复现率,提高编程效率。因为我们定义好函数之后便可无限使用,把某些经常需要用的功能分离出来就可以少写很多代码,这个真的非常重要。 最后就是便于以后添加新功能,只要耦合度低了,要添加新功能时只要把前后衔接剪断,再接入新函数的两端即可。但是如果写成了一整个函数。。。后果不堪设想。

⑵语义化!一定要语义化!

设想有一天你接手别人的项目,突然出现那么一个函数:“aaa()”你说你气不气?这是干嘛的?什么意思?是不是想冲上去拧着那个人的耳朵到屏幕跟前让他解释一下? 别说别人写的,某天自己的代码中出现一个当初随便起名的函数,你自己也恨不得扇自己两巴掌,但没办法,一小时写代码,两小时读代码,这样效率真的是太低了! 看看课本上计算器那个例题,命名12个button就是b1一直到b12,可千万别跟他学,0到9尚且好说,如果让你说说乘号是b几,也得一个一个找吗? 除了这些,某些语言自己就有命名标准,什么camel\\pascal命名标准,至少自己应该了解一下,以后看见名字得知道应该是个什么东西才行。 还有如果可以的话尽量为每个函数加注释,一般是两个内容,即函数的作用函数的传入值是什么,返回值是什么。有了注释在将来维护的时候就可以大大减少工作量。

⑶类的功能要唯一,该是什么类就干什么事

再揪出课本来批:把main函数写在Student类里这种事以后可千万别干,Student是学生类,学生和main函数有什么关系?如果要写main函数专门建一个类,Student就是Student,Student不需要、也不能够包含main函数。 暂时想起这些,如果还有想起来的还会更新。


③的答案: 不能编译通过,因为其内部类Grading与QSGrading未实例化,应该分别将其实例化。改正方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
public static void main(String[] args)
{
Student student1=new Student();
Student student2=new QSStudent();
QSStudent student3=new QSStudent();

student1.grading=student1.new Grading();
student1.grading.english=90;
student2.grading=student2.new Grading();
student2.grading.english=90;
student3.grading=student3.new QSGrading();
student3.grading.english=90;
}

最后再唠叨一下:之所以实例化student2的内部类使用Grading的构造方法而不是QSGrading的构造方法,是因为student2是使用多态产生的QSStudent对象,其中父类的内部类Grading与子类的内部类QSGrading类名不同,所以父类的内部类无法连接到子类的内部类,进而导致其中内部类只有Grading类没有QSGrading类。没有QSGrading类就当然没办法使用其构造方法了。


最后祝各位空指针2019年都能找到对象