视频:《零基础学习Android开发》第五课 类与面向对象编程1-1
类的定义、成员变量、构造方法、成员方法
一、从数据与逻辑相互关系审视代码
通过前面的课程,我们不断接触Java语言的知识,不断增加自己的语言表达能力。到现在为止,我已经可以高兴地向大家宣布你的语言能力其实已经可以让完成一个完整的程序了!可是,有的同学会问,那不对啊!我现在还知道怎么操作图形界面,不知道文件怎么读写,不知道数据库如何访问,不知道网络如何连接……这个不用急,因为这些并不属于语言的范围,而是关于如何使用平台资源的事。并且这些使用资源的知识你们只是不知道该如何找,找到了学起来其实是非常简单的。再说,这些内容我们后面的课也会讲的。但是,有一个问题。那就是我们现在所有的代码都是写在一个文件里的,也就是在MainActivity.java里。如果要做的事多了,复杂了,肯定要把代码分到不同文件里,这样才使代码文件有一个比较好的组织结构,今后维护也方便些。那该依据什么逻辑原则来划分文件呢?
我们重新看一下现在已经写了的代码,就会发现,代码就是由数据结构与算法组成的。一部分代码就是讲这是什么数,这些数是怎么组织的。另一部分代码就是讲数该如何算。再看一下就会发现,有些数据结构与一些逻辑代码之间的联系很紧密,而与另一些逻辑代码的关系则不大。比如说,
int[] pokers = new int[52]; // 一副牌组成的数组 for(int i = 0; i < 52; i++){ pokers[i] = i + 1; // 初始化牌数组,序号从0~51,点数从1到52 } pokers = shuffle(pokers); // 调用洗牌方法
可以看到pokers这个数组与初始化数组、洗牌算法的关系比较紧密,在后面的代码中,只在发牌的时候才引用了一次。而表示每个玩家手里的牌的数组则是与叫牌、拿牌、求和、亮牌等逻辑代码相关度较高,而且因为有两个玩家,所以类似的逻辑代码有两处。这样就给了我们一个印象,那就是在代码中,一些数据和一些逻辑的关系是紧密的,是应该在一起的。我们通过前面的学习知道了数据与数据是可以组合在一起成为数据结构的,比如数组。那有没有可能把数据与处理它的逻辑代码组合在一起也做成一种结构呢?有的。这就是“类”。在介绍类的基本概念之前,我们可以试着想想按刚才所说的数据和逻辑的组合要构造类的话应该包括哪些内容。首先说第一组。这可以概括成一副扑克牌,它的内容包括:
- 一个代表牌的长度为52的整型数组(数据)
- 将从1~52的数字依次放入数组的初始化代码(逻辑)
- 将数组中的值打乱的洗牌代码(逻辑)
- 后面的发牌其实就是从这个数组中按序取出一个元素来,也是对这个牌的数组数据的处理代码,因此应该包括发牌的代码(逻辑)
根据以上分析,我先把扑克牌的“类”写出来。在IDE左侧的项目工具窗口中右击“app>java>com.example.helloworld”,在弹出菜单中选择“New>Java Class”,在对话框的Name一栏中填入类名Pokers,这样就新建了一个Pokers.java文件,在该类文件中敲如下代码:
package com.example.helloworld;public class Pokers { private int[] pokers; // 成员变量,一副牌的数组 private int index; // 成员变量,表示当前发牌的序号 // 构造方法 public Pokers(){ index = 0; this.pokers = new int[52]; // 创建成员变量数组 for(int i = 0; i < 52; i++){ pokers[i] = i + 1; // 初始化牌数组,序号从0~51,点数从1到52 } pokers = shuffle(pokers); // 洗牌 } // 成员方法。洗牌 private int[] shuffle(int[] nums) { java.util.Random rnd = new java.util.Random(); for (int i = nums.length - 1; i > 0; i--) { int j = rnd.nextInt(i + 1); int temp = nums[i]; nums[i] = nums[j]; nums[j] = temp; } return nums; } // 成员方法。发一张牌,即给调用者返回一张牌的数值 public int getNextPoker(){ int poker = pokers[index]; index++; // 发完一张牌后序号加1 return poker; }}
同学们可以看到,在这个类Pokers里,有数据pokers和index,还有逻辑代码,即对这些数据进行处理的方法Pokers、shuffle和getNextPoker,分别完成初始化、洗牌与发牌的功能。这就是我们构建的第一个类。
二、类
1. 类的基本组成
从上面的Pokers类可以看到,组成一个类的基本部分是4个,分别是类声明、成员变量、构造方法与成员方法。
在Pokers类的声明public class Pokers中,public是访问修改符。类的访问修饰符共有4种,我们先不讲有何区别,我们都写为public就行。class是关键词,表明这是一个类。Pokers是类名,类名的第一个字母一般大写,如果是由多个单词组合成的,则每个单词第一个字母都大写。
pokers和index都是成员变量,英文是field(也有的翻译成“域”或“字段”),成员变量在整个类的范围内都能进行访问,因此它是不同于方法内部声明的变量(那个叫部局部变量)的,也可以说成员变量的作用域是整个类。在它们之前的private是访问修饰符,这表明它是私有的,意思是只能在本类的内部才能访问,而不能被类之外的调用者访问。如果将修饰符改为public就可以让类外的调用者访问,但是最好不要使用这种方式。因为这种方式使得类内部的数据可以被随意改变,这很容易失控。类里的数据应该通过类的成员方法来访问,这样取值的时候可以根据需要对数值经过某种转换再给出,比如我们的程序里数组里存的是牌的序号,可以将其转为实际点数再给出。改变数值的时候,也可以加入某些限制免得超出范围。比如我们的数组中存入的值就应该在1~52之间。
与类名相同的方法是构造方法,英文是constructor,在类的变量被创建(或者叫“初始化”。大家记得吧,类是引用类型,因此创建类的对象要用new)时首先被调用。构造方法没有返回值,参数列表可以有也可以没有。构造方法可以是多个。如果没有给出构造方法,编译时会自动生成一个不带参数列表的默认构造方法。构造方法访问修饰符有public、protected、private3种,可以从中任选一种,我们现在暂时只用public,也就是公共的,可以让外部调用的。private的成员变量没有默认值,必须被初始化,在构造方法里对成员变量进行初始化,是比较合适的,当然也可以进行其它操作。
方法在上一课已经讲了,在类的内部就叫成员方法。这里主要讲一下它的访问修饰符,这里在shuffle前面是private,意思是私有的,只能本类中被调用,而getNextPoker之前是public,意思是公共的,可以在其它类中被调用。
以上类的4个组成部分,除了类声明是必须的,其它都是可选的。可以只要成员变量,比如我们声明一个学生类:
public class Student{ public int index;// 学号 public String name;// 姓名}
这样就是一个纯数据结构,其中的成员变量必须是public的,不然外部访问不到就没有意义。同学们可以看到,这种数据结构就可以把不同类型的数据组织在一起。上一课中方法输出时也如果把sum与text两个变量组织成一个类,就可以输出两种数据。
但是这种在成员变量之前加public的用法没有把类的优势发挥出来。如果只是想用类来组合一个数据结构,最好也是将成员变量设为private,而用成员方法来对成员变量进行存取。上面的Student可以改为如下代码:
public class Student{ private int index; // 学号,私有的成员变量,外部不可访问 private String name; // 姓名,私有的成员变量,外部不可访问 public Student(){ // 私有成员变量没有默认值,必须初始化 this.index = 0; this.name = ""; } public int getIndex() { // 获得学号,公共的成员方法,外部可访问 return this.index; } public void setIndex(int index) { // 设置学号,公共的成员方法,外部可访问 this.index = index; } public String getName() { // 获得姓名,公共的成员方法,外部可访问 return this.name; } public void setName(String name) { // 设置姓名,公共的成员方法,外部可访问 this.name = name; }}
在存与取的方法里都可以加上一些对数据进行限制或转换的代码,这样可以保证数据的安全,也可以增强数据的外部可读性。
这里可以看到访问成员变量时,用的是“this.”的方式,this表明的是本类的对象,“.”是成员访问操作符。通过在index与name前面加上“this.”就表明是成员变量,就可以与参数列表里的参数变量进行区别。我给大家举这个例子呢,目的是让大家不要对类的概念产生陌生感,你完全可以把类就看成一个数据结构,但是这个数据结构里不仅放了数据,还放了处理这些数据的方法。这样程序文件就有了很好的组织性。
也可以不要成员变量,只要成员方法。比如:
public class ToolsClass{ public void doSth(){ } public int getNum(){ return 0; }}
内部只有方法的类一般作为工具类,因为其成员方法的代码与成员变量无关,可以在方法前上static,静态的,这就成为静态方法。上面的类可以改为:
public class ToolsClass{ public static void doSth(){ } public static int getNum(){ return 0; }}
这样的好处是调用时不需要先用new来创建类的对象,而是可以直接通过类名来调用,如ToolsClass.doSth()、ToolsClass.getNum()就可以。static不仅可以加到成员方法前面,也可以加到成员变量前,至于静态与非静态成员的区别,我在后面再讲。
2. 创建类的对象并访问其成员
因为类是引用类型,因此创建类的变量(也称为对象Object、实例Instance)是用new,创建出对象后,就可以通过该对象加“.”来访问其公共成员方法或变量了。比如上面声明的Student类:
Student s = new Student(); s.setIndex(1); s.setName("Tom"); int index = s.getIndex(); String name = s.getName();
三、用类来重新组织代码
我们重新看一遍已经建立的Pokers类,它是将对一副扑克牌相关的数据与逻辑都移入该类之中(术语叫“封装”,英文为Encapsulation)。现在我们把与它相关的代码变换为使用Pokers的对象来进行。原来的代码为:
int[] pokers = new int[52]; // 一副牌组成的数组 for(int i = 0; i < 52; i++){ pokers[i] = i + 1; // 初始化牌数组,序号从0~51,点数从1到52 } pokers = shuffle(pokers); // 调用洗牌方法
调整后的代码为:
Pokers pokers = new Pokers();
因为我们把顺序点数放牌数组及洗牌的动作全放在构造函数里了,因此当Pokers类的对象pokers被创建时,我们就有了一副已经洗好的牌了。在调用者的角度,屏蔽了很多细节,同时如果发现与扑克牌相关的数据与逻辑有问题的话,也能很快定位到Pokers类中,不用去其它地方找Bug。这就是封装的好处。后面发牌的动作,也用pokers.getNextPoker()一条语句就可以了。调用者不用考虑具体的实现细节,只要知道要调用类对象中的哪个方法能得到自己想要的东西就行。按照这个思路,程序的主要逻辑就可以变得非常的简洁。主要逻辑不用操心细节,就需要知道把工作“分类”,不同的工作分给不同的类,再找这些类的对象要东西就行。是不是有点领导的感觉了?事来了,这个事是业务部的,那个事是财务部,那个事是人事部的,通通分下去,再找业务部安排的小李、财务部的小刘和人事的小张,跟他们要东西,这事就办妥了。
我们接着往下看代码。我们看一下原来的代码:
int[] pokersA = new int[4]; // 玩家手里的牌 int[] pokersB = {7, 8, 0, 0}; // 电脑手里的牌 for(int i = 0; i < 4; i++){ pokersA[i] = pokers.getNextPoker();// 改为通过Pokers类对象获得下一张牌 // 获得当前玩家手中牌的总点数 String[] texts = {""}; int sum = getSum(pokersA, texts); // 在玩家手中牌点数大于电脑时停止叫牌 if(sum > 15){ break; } } // 显示玩家手里的牌及总数 String[] texts = {"你手中的牌分别是:"} ; int sumA = getSum(pokersA, texts); texts[0] += "总数是:" + sumA + "。对家手里的牌是:"; // 显示电脑手里的牌及总数 int sumB = getSum(pokersB, texts);
这里是否还有数据与逻辑相关似很强呢?有的,数组pokersA与发牌、结算的逻辑相关性强,数组pokersB没有发牌逻辑,是因为我们做了简化,其实是应该有的,它也有结算逻辑。pokersA与pokersB代表的是两个玩家手里的牌,我们是否需要建立两个类呢?答案是不需要,因为虽然是两个玩家,但是每个玩家数据的结构以及操作数据的逻辑是完全相同的,唯一不同的是数据的值。而数据的值是在类的对象并创建之后再通过各种方法去设置的,因此值的差异只是对象之间的差异,而不是类的差异。就像int类型的两个变量,它们的值不同,但它们都是int型的。如果它们的值相同,那也是同类型的不同变量。相同的,同一类的不同对象,它们的成员变量的值不同,但都是同一类的。就算他们的成员变量值相同,也是同一类的不同变量。这也好比人就是一个类,人与人之间的很多数据的值是不一样的,高矮胖瘦、年龄姓名、生活经历,但是基本功能是相同的,能跑能跳能吃能睡。只是因为其数据值的不同,造成其功能运行时的差异。在这个游戏里,两个玩家的功能(即行为逻辑)是相同的,出现差异的原因是其成员变量的值不同造成的。因此,它们是同类的不同对象。我们归纳一下玩家类的包括哪些内容:
- 一个代表要到的牌的数组(数据)
- 接收牌的功能(逻辑)
- 汇总手中牌点数的功能(逻辑)
- 给出结算信息的功能(逻辑)
根据以上信息,我们可以写出下面的“玩家类”:
public class Player { private int[] pokers; private int index; public Player(){ this.pokers = new int[12]; // 最极端情况下拿到4个A,4个2,4个3,因此长度设为12即可 this.index = 0; } // 要一张牌 public boolean wantPoker(int num){ if (this.index >= 12) { // 限制性代码,避免数组序号出错 return false; } this.pokers[index] = num; index++; // 序号加1 return true; } // 获得当前的点数之和 public int getSum(){ int sum = 0; for (int i = 0; i < this.pokers.length; i++){ if (pokers[i] == 0){ break; } sum += (pokers[i] - 1) % 13 + 1; // 获得真正点数 } return sum; } // 获得牌局结算信息 public String getStatString(){ String txt = ""; for (int i = 0; i < this.pokers.length; i++){ if (pokers[i] == 0){ break; } switch ((pokers[i] - 1) / 13){ // 根据真正的花色的值进行选择 case 0:{ txt += "黑桃"; break; } case 1:{ txt += "红桃"; break; } case 2:{ txt += "梅花"; break; } case 3:{ txt += "方片"; break; } } txt += ((pokers[i] - 1) % 13 + 1) + ","; // 获得真正点数的字符串 } return txt; }}
上面这个Player类就实现了我们对它的要求。但是看这段代码我们可以发现一个问题:原来我们说所有与扑克牌实现的细节是被封装在Pokers类里面的,但是在Player类的getSum与getStatString两个方法里,却要知道关于牌的很多细节,要知道真正的点数值与花色值如何求出,还要知道如何将花色值翻译成字符表达,这是与我们上面表达的原则是相违背的。因此,这两个方法里的很多实现的细节应该被放到Pokers类里去。我们对Pokers类进行扩充,加几个方法:
// 获得真正点数public static int getCount(int num){ return (num - 1) % 13 + 1;}//从牌的序号中得出花色值public static int getColor(int num){ return (num - 1) / 13; }// 从牌的序号中得出花色值的文字表达public static String getColorString(int num){ String txt = ""; switch (getColor(num)){ case 0:{ txt = "黑桃"; break; } case 1:{ txt = "红桃"; break; } case 2:{ txt = "梅花"; break; } case 3:{ txt = "方片"; break; } } return txt;}
这样关于扑克牌的实现细节就真正地全部封装在Pokers类里了。因为这3个方法并不是对Pokers的成员变量进行操作,也就不需要成为对象方法(对象方法需要通过实例化的类的对象才能调用),我们在前面加上static修饰符,使其成为静态方法,这样调用时直接使用类名就可以调用了。再重构Player类为如下代码:
public class Player { private int[] pokers; private int index; public Player(){ this.pokers = new int[12]; // 最极端情况下拿到4个A,4个2,4个3,因此长度设为12即可 this.index = 0; } // 要一张牌 public boolean wantPoker(int num){ if (this.index >= 12) { // 限制性代码,避免数组序号出错 return false; } this.pokers[index] = num; index++; // 序号加1 return true; } // 获得当前的点数之和 public int getSum(){ int sum = 0; for (int i = 0; i < this.pokers.length; i++){ if (pokers[i] == 0){ // 为0时表示当前已经没牌了 break; } sum += Pokers.getCount(pokers[i]); // 获得真正点数 } return sum; } // 获得牌局结算信息 public String getStatString(){ String txt = ""; for (int i = 0; i < this.pokers.length; i++){ if (pokers[i] == 0){ break; } txt += Pokers.getColorString(pokers[i]) + Pokers.getCount(pokers[i]) + ","; } return txt; }}
现在我们已经建立了管牌的Pokers类和玩牌的Player类,那么一个牌局就可组起来了。让我们开干,把主程序的调用代码改了:
Pokers pokers = new Pokers(); pokers.getNextPoker(); Player p1 = new Player(); Player p2 = new Player(); for(int i = 0; i < 4; i++){ p1.wantPoker(pokers.getNextPoker()); p2.wantPoker(pokers.getNextPoker()); } String text = "player1:" + p1.getStatString() + "总数:" + p1.getSum() + ",player2:" + p2.getStatString() + "总数:" + p2.getSum(); TextView txtResult = (TextView)findViewById(R.id.txtResult); txtResult.setText(text);
运行以后,效果如下:
同学们可以看到变为使用类以后,主程序的逻辑变得清晰简单了(因为我们还没有做是否要牌的决策方法,就用每个玩家发4张牌来代替),每个具体的工作都有专门的类来管理与执行,主逻辑只需要指挥(调用)对应的对象就能把整个任务完成好。这看起来是不是就很像我们真实社会运行的状态了呢?我们可以看到我们的代码发生重大的变化,这是因为我们对代码的组织方式发生了变化,而这个新的方式就是“面向对象编程”。