面向对象程序设计·学习笔记(2/2)

前言

  课程源自中国大学MOOC:《面向对象程序设计——Java语言》(浙江大学·翁恺),链接: link.


第5章 设计原则

5.1 城堡游戏

  即使类的设计很糟糕,也可能实现一个应用程序,使之运行并完成所需的工作,但这并不能表明程序内部的结构是否良好。 当维护程序员想要对一个已有的软件做修改的时候,问题才会浮现出来。比如,程序员试图纠正已有软件的缺陷,或者为其增加一些新的功能

  显然,如果类的设计良好,这个任务就可能很轻松;而如果类的设计很差,那就会变得很困难,要牵扯大量的工作。在大的应用软件中,这样的情形在最初的实现中就会发生了。如果以不好的结构来实现软件,那么后面的工作可能变得很复杂,整个程序可能根本无法完成,或者充满缺陷,或者花费比实际需要多得多的时间才能完成。

  在现实中,一个公司通常要维护、扩展和销售一个软件很多年,很可能今天在商店买到的软件,其最初的版本是在十多年前就开始了的。在这种情形下, 任何软件公司都不能忍受不良结构的代码。 既然很多不良设计的效果会在试图调整或扩展软件时明显地展现出来,那么就应该以调整或扩展软件来鉴别和发现这样的不良设计

  下面将通过一个叫作城堡游戏的例子来学习,这个例子很简单,基本实现了一个基于字符的探险游戏。我们的目的是将下面的初始代码进行改进:
Game.java:

package castle;

import java.util.Scanner;

public class Game {
	private Room currentRoom;

	public Game() {
		createRooms();
	}

	private void createRooms() {
		Room outside, lobby, pub, study, bedroom;

		// 制造房间
		outside = new Room("城堡外");
		lobby = new Room("大堂");
		pub = new Room("小酒吧");
		study = new Room("书房");
		bedroom = new Room("卧室");

		// 初始化房间的出口
		outside.setExits(null, lobby, study, pub);
		lobby.setExits(null, null, null, outside);
		pub.setExits(null, outside, null, null);
		study.setExits(outside, bedroom, null, null);
		bedroom.setExits(null, null, null, study);

		currentRoom = outside; // 从城堡门外开始
	}

	private void printWelcome() {
		System.out.println();
		System.out.println("欢迎来到城堡!");
		System.out.println("这是一个超级无聊的游戏。");
		System.out.println("如果需要帮助,请输入 'help' 。");
		System.out.println();
		System.out.println("现在你在" + currentRoom);
		System.out.print("出口有:");
		if (currentRoom.northExit != null)
			System.out.print("north ");
		if (currentRoom.eastExit != null)
			System.out.print("east ");
		if (currentRoom.southExit != null)
			System.out.print("south ");
		if (currentRoom.westExit != null)
			System.out.print("west ");
		System.out.println();
	}

	// 以下为用户命令

	private void printHelp() {
		System.out.print("迷路了吗?你可以做的命令有:go bye help");
		System.out.println("如:\tgo east");
	}

	private void goRoom(String direction) {
		Room nextRoom = null;
		if (direction.equals("north")) {
			nextRoom = currentRoom.northExit;
		}
		if (direction.equals("east")) {
			nextRoom = currentRoom.eastExit;
		}
		if (direction.equals("south")) {
			nextRoom = currentRoom.southExit;
		}
		if (direction.equals("west")) {
			nextRoom = currentRoom.westExit;
		}

		if (nextRoom == null) {
			System.out.println("那里没有门!");
		} else {
			currentRoom = nextRoom;
			System.out.println("你在" + currentRoom);
			System.out.print("出口有: ");
			if (currentRoom.northExit != null)
				System.out.print("north ");
			if (currentRoom.eastExit != null)
				System.out.print("east ");
			if (currentRoom.southExit != null)
				System.out.print("south ");
			if (currentRoom.westExit != null)
				System.out.print("west ");
			System.out.println();
		}
	}

	public static void main(String[] args) {
		Scanner in = new Scanner(System.in);
		Game game = new Game();
		game.printWelcome();

		while (true) {
			String line = in.nextLine();
			String[] words = line.split(" ");
			if (words[0].equals("help")) {
				game.printHelp();
			} else if (words[0].equals("go")) {
				game.goRoom(words[1]);
			} else if (words[0].equals("bye")) {
				break;
			}
		}

		System.out.println("感谢您的光临。再见!");
		in.close();
	}
}

Room.java

package castle;

public class Room {
	public String description;
	public Room northExit;
	public Room southExit;
	public Room eastExit;
	public Room westExit;

	public Room(String description) {
		this.description = description;
	}

	public void setExits(Room north, Room east, Room south, Room west) {
		if (north != null)
			northExit = north;
		if (east != null)
			eastExit = east;
		if (south != null)
			southExit = south;
		if (west != null)
			westExit = west;
	}

	@Override
	public String toString() {
		return description;
	}
}

5.2 消除代码复制

  程序中存在相似甚至相同的代码块,是非常低级的代码质量问题。代码复制是设计不良的一种表现。
  代码复制存在的问题是,如果需要修改一个副本,那么就必须同时修改所有其他的副本,否则就存在不一致的问题。这增加了维护程序员的工作量,而且存在造成错误的潜在危险。很可能发生的一种情况是,维护程序员看到一个副本被修改好了,就以为所有要修改的地方都已经改好了。因为没有任何明显迹象可以表明另外还有一份一样的副本代码存在,所以很可能会遗漏还没被修改的地方。
  我们从消除代码复制开始。消除代码复制的两个基本手段,就是函数和父类

  在这里,将初始代码中的“现在你在…出口有…”做成一个public void showPrompt()的函数,代码中直接调用函数即可。总的代码修改见5.5节。

5.3 封装

  要评判某些设计比其他的设计优秀,就得定义一些在类的设计中重要的术语,以用来讨论设计的优劣。对于类的设计来说,有两个核心术语:耦合 (类与类)和 聚合(一个类)。 耦合这个词指的是类和类之间的联系。之前的章节中提到过,程序设计的目标是一系列通过定义明确的接口通信来协同工作的类耦合度反映了这些类联系的紧密度。我们努力要获得低的耦合度,或者叫作松耦合(loose coupling)

  耦合度决定修改应用程序的容易程度。在一个紧耦合的结构中,对一个类的修改也会导致对其他一些类的修改。这是要努力避免的,否则,一点小小的改变就可能使整个应用程序发生改变。另外,要想找到所有需要修改的地方,并一一加以修改,却是一件既困难又费时的事情。 另一方面,在一个松耦合的系统中,常常可以修改一个类,但同时不会修改其他类,而且整个程序还可以正常运作。

  聚合与程序中一个单独的单元所承担的任务的数量和种类相对应有关,它是针对类或方法这样大小的程序单元而言的。理想情况下,一个代码单元应该负责一个聚合的任务(也就是说,一个任务可以被看作是 一个逻辑单元)。一个方法应该实现一个逻辑操作,而一个类应该代表一定类型的实体。聚合理论背后的要点是重用:如果一个方法或类是只负责一件定义明确的事情,那么就很有可能在另外不同的上下文环境中使用。遵循这个理论的一个额外的好处是,当程序某部分的代码需要改变时,在某个代码单元中很可能会找到所有需要改变的相关代码段。

  代码不是能运行就是好代码,有一个重要的标准就是能适应将来的需要:可维护。

  这里我们将Room类中的变量改为private,通过新的接口将数据保护起来。注意到Game类中需要的其实并不是那些变量本身,而是由Room中的这些变量得到的一些结论,因此我们将Room中的变量和Game中需要的对这些变量的操作进行封装。然后在Game中直接调用相应接口。

  强调几个细节:

  1. 因为String是一个immutable的类型,因此每一次创建都是新的创建,开销很大,所以由较多的字符串经过一些复杂的操作得到一个总的字符串时,不是字符串一直加。而是用StringBuffer类,比如
StringBuffer sb = new StringBuffer(); sb.append("字符串1"); sb.append("字符串2");……
  1. 单一出口原则:
       比如上面的输出一个String,最差的是情况是做一次判断,就新建一个String,且每次都System.out.print()。实际上,完全可以用StringBuffer将要输出的所有字符串按需要的输出格式放一起后,再统一输出。另外,还有个细节是,输出其实可以换成是返回需要的字符串的函数,再配合系统的print即可,这样虽然再有些时候略显多余,但有更大的扩展空间——比如以后不用print,而是把字符串通过一个图像界面输出。
       又比如多次判断下返回任意类型的对象,养成习惯,一开始就新建个这样的对象变量ret(return)并初始化为null,最后再返回ret,然后再填补中间的判断来改变对象变量管理的内容(后面也会根据需要简化)。

5.4 可扩展性

   优秀的软件设计者的一个素质就是有预见性。什么是可能会改变的?什么是可以假设在软件的生命期内不会改变的?在游戏的很多类中硬编码进去的一个假设是,这个游戏会是一个基于字符界面的游戏,通过终端进行输入输出,会永远是这样子吗? 以后如果给这个游戏加上图形用户界面,加上菜单、按钮和图像,也是很有意思的一种扩 展。如果这样的话,就不必再在终端上打印输出任何文字信息。还是可能保留着命令字,还是会在玩家输入帮助命令的时候显示命令字帮助文本,但是可能是显示在窗口的文字域中,而不是使用System.out.println()。

  可扩展性的意思就是代码的某些部分不需要经过修改就能适应将来可能的变化

  通过前面的封装,降低了耦合之后,虽然还没有直接实现可扩展性(加入新的方向),但是打下了很好的基础。因为这是用接口来实现聚合,即给Room类实现的新方法,把方向的细节彻底隐藏在Room类内部。那么,今后方向如何实现就和外部无关了。

  进一步,“用容器来实现灵活性”:为了提高成员变量的灵活性,可以用容器来表示一批有可扩展性的变量,比如存放的Item。又比如这里的方向,Room的方向是用成员变量表示的,增加或减少方向就要改变代码;如果用Hash表来表示方向,那么方向就不是“硬编码”的了。

  这样编码下的Room类,通过将方向变量设为容器,在方向上就体现出了可扩展性,增加up和down等新的方向,不需要改变Room类,只需在Game类中添加相应初始化定义即可。

5.5 框架加数据

  概述:从程序中识别出框架和数据以代码实现框架将部分功能以数据的方式加载,这样能在很大程度上实现可扩展性。

  在前面的例子中,我们对Room类做了改进,把原本以给定的成员变量来做方向上的硬编码,变成了用容器Hash表。这给我们以启发:原本是硬编码的东西,尽可能解成 框架+数据的结构,即做成Hash表等数据结构、各种接口函数等 框架,再加上数据(放在Hash表等数据结构里的东西),以 框架+数据提高可扩展性

  在这里,除了上面的方向成员变量是硬编码,还有另一个硬编码,命令解析:一个String的命令(对象)对应上一个方法函数(不再是对象)。我们现在有三个命令,go、help、bye,每个命令去调用一个函数,这时不能再用Hashmap<key,value>这种数据结构,因为hash表要求key和value都是对象,而函数不是对象。
  但注意到,函数可以放在类里,那么

  • 命令的解析是否可以 脱离if-else
  • 定义一个Handler类 来处理命令,
  • Hash表 来保存 命令和Handler 之间的关系。

  从原理上来说,目标是建立对象和函数的数据结构,为了保持可扩展性,那么对这些相似的函数统一命名,这里命名为执行命名doCmd();具体区分执行哪个函数时,可以利用 子类方法对父类方法的覆盖 来完成。

  因此,我们建立一个叫Handler的父类,具体的函数建立相应的子类(如HandlerGo),建立String(key)到Handler(value)的Hash表,视String的不同,给Hash表这个容器里放不同的子类对象,然后都执行同名的doCmd(参数1,参数2,…)函数(PS1:各子类的函数进行了相应的覆盖所以执行结果不同;PS2:每个方法需要的参数不完全一样,则doCmd函数参数取并集,比如这里的goRoom(String dir)和printHelp()参数进行合并,得到父类函数doCmd(String word) )。

  这样以后增加新方法时,只需新写一个Handler的相应子类,然后在构造函数中给Hash表添加相应的String-Handler子类映射,那么其余代码不变,即可实现扩展。

  这里在具体实现时,新建的Handler类的子类如HandlerGo中要调用Game类中的函数(即一个类调用其他类的函数),因此这里采用一种较曲折的方法(后面会有新的方法,6.3的狐狸与兔子游戏中:通过数据与表现分离的设计方式,将调用放在专门处理事务逻辑的地方),这里先在父类Handler中建立一个Game类型的成员game和相应的构造函数,然后每个子类建立对应的构造函数并通过super(game)调用父类的构造函数得到game,这样就建立了关于game的指针,可在子类中调用game的方法了。

  以上几小节的代码如下:
Game.java

package castle;
import java.util.HashMap;
import java.util.Scanner;

public class Game {
	private Room currentRoom;
	private HashMap<String, Handler> handlers = new HashMap<String, Handler>();

	public Game() {
		createRooms();
		addHandlers();
	}

	private void addHandlers() {
		handlers.put("go", new HandlerGo(this));
		handlers.put("help", new HandlerHelp(this));
		handlers.put("bye", new HandlerBye(this));
	}
	
	private void createRooms() {
		Room outside, lobby, pub, study, bedroom;

		// 制造房间
		outside = new Room("城堡外");
		lobby = new Room("大堂");
		pub = new Room("小酒吧");
		study = new Room("书房");
		bedroom = new Room("卧室");

		// 初始化房间的出口
		outside.setExit("east", lobby);
		outside.setExit("south", study);
		outside.setExit("west", pub);
		lobby.setExit("west", outside);
		pub.setExit("east", outside);
		study.setExit("north",outside);
		study.setExit("east", bedroom);
		bedroom.setExit("west", study);
		pub.setExit("up", bedroom);
		bedroom.setExit("down", pub);

		currentRoom = outside; // 从城堡门外开始
	}

	public void showPrompt() {
		System.out.println("现在你在" + currentRoom);
		System.out.print("出口有:");
		System.out.print(currentRoom.getExitdesc()+"\n");
	}
	
	private void printWelcome() {
		System.out.println();
		System.out.println("欢迎来到城堡!");
		System.out.println("这是一个超级无聊的游戏。");
		System.out.println("如果需要帮助,请输入 'help' 。");
		System.out.println();
		showPrompt();
	}

	// 以下为用户命令
	public void goRoom(String direction) {
		Room nextRoom = currentRoom.getExit(direction);
		if (nextRoom == null) {
			System.out.println("那里没有门!");
		} else {
			currentRoom = nextRoom;
			showPrompt();
		}
	}

	public void play() {
		Scanner in = new Scanner(System.in);
		while (true) {
			String line = in.nextLine();
			String[] words = line.split(" ");
			
			Handler handler = handlers.get(words[0]);
			String v = words[0];
			if (words.length > 1) {v = words[1];}
			if (handler != null) {
				handler.doCmd(v);
				if (handler.isBye() ) {break;}
			}	
		}
		in.close();
	}
	
	public static void main(String[] args) {
		Game game = new Game();
		game.printWelcome();
		game.play();
	}
}

Room.java

package castle;
import java.util.HashMap;

public class Room {
	private String description;
	private HashMap<String, Room> exits = new HashMap<String, Room>();

	public Room(String description) {
		this.description = description;
	}

	public void setExit(String dir, Room room) {
		exits.put(dir, room);
	}

	public String getExitdesc() {
		StringBuffer sb = new StringBuffer();
		for (String dir : exits.keySet()) {
			sb.append(dir);
			sb.append(" ");
		}
		return sb.toString();
	}

	public Room getExit(String direction) {
		return exits.get(direction);
	}

	@Override
	public String toString() {
		return description;
	}
}

Handler.java

package castle;

public class Handler {	
	protected Game game;
	public Handler(Game game) {
		this.game = game;
	}
	
	public void doCmd(String word) {}
	public boolean isBye() { return false; }
}

HandlerGo.java

package castle;

public class HandlerGo extends Handler {
	public HandlerGo(Game game) {
		super(game);
	}
	
	@Override
	public void doCmd(String word) {		
		super.doCmd(word);
		game.goRoom(word);
	}
}

HandlerHelp.java

package castle;

public class HandlerHelp extends Handler {
	public HandlerHelp(Game game) {
		super(game);
	}
	
	@Override
	public void doCmd(String word) {
		System.out.print("迷路了吗?你可以做的命令有:go、bye、help,如: go east\n");
	}
}

HandlerBye.java

package castle;

public class HandlerBye extends Handler {
	public HandlerBye(Game game) {
		super(game);
	}
	
	@Override
	public boolean isBye() {
		return true;
	}

	@Override
	public void doCmd(String word) {
		System.out.println("感谢您的光临。再见!");
	}
}

第6章 抽象与接口

6.1 抽象

  在第一周就有一个Shape类的例子。这个类有很多的子类,每个子类也都实现了父类的方法。实际上父类Shape只是一个抽象的概念而并没有实际的意义。如果请你画一个圆,你知道该怎么画;如果请你画一个矩形,你也知道该怎么画。但是如果我说:“请画一个形状,句号”。你该怎么画?同样,我们可以定义Circle类和Rectangle类的draw(),但是Shape类的draw()呢?

  Shape类表达的是一种概念,一种共同属性的抽象集合,我们并不希望任何Shape类的对象会被创建出来。那么,我们就应该把这个Shape类定义为抽象的。我们用abstract关键字来定义抽象类。抽象类的作用仅仅是表达接口,而不是具体的实现细节

  抽象类中可以存在抽象方法。抽象方法也是使用abstract关键字来修饰。抽象的方法是不完全的,它只是一个方法签名而完全没有方法体。即没有方法函数大括号 { } 里的内容。抽象函数不能有括号 { },而是形如 public abstract void draw(Graphics g);

  如果一个类有了一个抽象的方法,这个类就必须声明为抽象类,必有abstract修饰词。如果父类是抽象类,那么子类必须覆盖所有在父类中的抽象方法,否则子类也成为一个抽象类。

  抽象类显然可以有非抽象方法(这里非抽象方法就是继承里的为了避免子类的代码重复),甚至一个抽象类可以没有任何抽象方法,所有的方法都有方法体,但是整个类是抽象的。设计这样的抽象类主要是为了防止制造它的对象出来抽象类型是无法实例化的(instantiate),即无法制造对象,在抽象类Shape中,令 Shape s = new Shape(); 是会出错的。

  抽象函数—— 表达概念而无具体函数体的函数;
  抽象类 —— 表达概念而无法构造出实体的类。

  抽象类不能制造对象,但是可以定义变量。任何 继承了该抽象类的非抽象(子)类的对象 可以赋给这个变量,或者说 抽象类定义的变量 可以管理 (继承该抽象类的非抽象)子类的对象。简而言之,抽象类变量是管理其非抽象子类的变量。

  因为抽象类的抽象函数是没有函数体的,所以继承自抽象类的子类 必须 覆盖 抽象类的 所有 抽象方法。
  因为是从无到有,由虚转实,所以我们在描述时称这种覆盖为 实现 / implement (后面的接口也是实现这个词);如果子类没去实现所有抽象方法,显然该子类依然是抽象类,这样实现部分抽象函数的抽象子类,不会出错,但依然不能制造相应的对象。
PS:“实现继承得到的抽象方法(implement the inherited abstracted method)"中的“实现”一般是出现在说明等语句描述中,在代码中依然是 @override 的覆盖,因为这种实现本身就是覆盖。

  下面澄清一下概念,Java中的抽象有两种:
  1. 与具体相对,表示一种概念而非实体,比如这里的抽象类;
  2. 与细节相对,表示在一定程度上忽略细节而而着眼大局,比如一台笔记本,而非一台CPU为i7-10750H、显卡为RTX 2080super、32G运存、2T固态硬盘……的17寸笔记本电脑。

6.2 数据与表现分离:细胞自动机

  有这样一个细胞自动机:它表示为一个动态的网格,每个网格表示一个细胞cell,它们随时间(循环次数)死亡或新生,具体规则为:如果活细胞的邻居的数量<2或>3,则死亡;如果正好有3个邻居活着,则新生;其他情况保持原样。

  首先给出程序,并附上看程序的两种方法:

  1. 直接看主程序的main函数,想办法读懂main函数的每一句,看每一句、段的任务,提到的类,再去看那个类。
  2. 去看最小的类,找到一个很细节的类,然后去追它的父类,再看其他的小类,看懂每一个细节部件,再把整个图给拼起来。

CellMachine.java

package cellmachine;
import javax.swing.JFrame;

import cell.Cell;
import field.Field;
import field.View;

public class CellMachine {

	public static void main(String[] args) {
		Field field = new Field(30,30);
		for ( int row = 0; row<field.getHeight(); row++ ) {
			for ( int col = 0; col<field.getWidth(); col++ ) {
				field.place(row, col, new Cell());
			}
		}
		for ( int row = 0; row<field.getHeight(); row++ ) {
			for ( int col = 0; col<field.getWidth(); col++ ) {
				Cell cell = field.get(row, col);
				if ( Math.random() < 0.2 ) {
					cell.reborn();
				}
			}
		}
		View view = new View(field);
		JFrame frame = new JFrame();
		frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
		frame.setResizable(true);
		frame.setTitle("Cells");
		frame.add(view);
		frame.pack();
		frame.setVisible(true);
		
		for ( int i=0; i<1000; i++ ) {
			for ( int row = 0; row<field.getHeight(); row++ ) {
				for ( int col = 0; col<field.getWidth(); col++ ) {
					Cell cell = field.get(row, col);
					Cell[] neighbour = field.getNeighbour(row, col);
					int numOfLive = 0;
					for ( Cell c : neighbour ) {
						if ( c.isAlive() ) {
							numOfLive++;
						}
					}
					System.out.print("["+row+"]["+col+"]:");
					System.out.print(cell.isAlive()?"live":"dead");
					System.out.print(":"+numOfLive+"-->");
					if ( cell.isAlive() ) {
						if ( numOfLive <2 || numOfLive >3 ) {
							cell.die();
							System.out.print("die");
						}
					} else if ( numOfLive == 3 ) {
						cell.reborn();
						System.out.print("reborn");
					}
					System.out.println();
				}
			}
			System.out.println("UPDATE");
			frame.repaint();
			try {
				Thread.sleep(200);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
		}
	}

}

Cell.java

package cell;
import java.awt.Graphics;
 
public class Cell {
	private boolean alive = false;
	
	public void die() { alive = false; }
	public void reborn() { alive = true; }
	public boolean isAlive() { return alive; }
	
	public void draw(Graphics g, int x, int y, int size) {
		g.drawRect(x, y, size, size);
		if ( alive ) {
			g.fillRect(x, y, size, size);
		}
	}
}

Field.java

package field;
import java.util.ArrayList;

import cell.Cell;

public class Field {
	private int width;
	private int height;
	private Cell[][] field;
	
	public Field(int width, int height) {
		this.width = width;
		this.height = height;
		field = new Cell[height][width];
	}
	
	public int getWidth() { return width; }
	public int getHeight() { return height; }
	
	public Cell place(int row, int col, Cell o) {
		Cell ret = field[row][col];
		field[row][col] = o;
		return ret;
	}
	
	public Cell get(int row, int col) {
		return field[row][col];
	}
	
	public Cell[] getNeighbour(int row, int col) {
		ArrayList<Cell> list = new ArrayList<Cell>();
		for ( int i=-1; i<2; i++ ) {
			for ( int j=-1; j<2; j++ ) {
				int r = row+i;
				int c = col+j;
				if ( r >-1 && r<height && c>-1 && c<width && !(r== row && c == col) ) {
					list.add(field[r][c]);
				}
			}
		}
		return list.toArray(new Cell[list.size()]);
	}
	
	public void clear() {
		for ( int i=0; i<height; i++ ) {
			for ( int j=0; j<width; j++ ) {
				field[i][j] = null;
			}
		}
	}
}

View.java

package field;

import java.awt.Dimension;
import java.awt.Graphics;

import javax.swing.JFrame;
import javax.swing.JPanel;

import cell.Cell;

public class View extends JPanel {
	private static final long serialVersionUID = -5258995676212660595L;
	private static final int GRID_SIZE = 16;
	private Field theField;
	
	public View(Field field) {
		theField = field;
	}

	@Override
	public void paint(Graphics g) {
		super.paint(g);
		for ( int row = 0; row<theField.getHeight(); row++ ) {
			for ( int col = 0; col<theField.getWidth(); col++ ) {
				Cell cell = theField.get(row, col);
				if ( cell != null ) {
					cell.draw(g, col*GRID_SIZE, row*GRID_SIZE, GRID_SIZE);
				}
			}
		}
	}

	@Override
	public Dimension getPreferredSize() {
		return new Dimension(theField.getWidth()*GRID_SIZE+1, theField.getHeight()*GRID_SIZE+1);
	}

	public static void main(String[] args) {
		Field field = new Field(10,10);
		for ( int row = 0; row<field.getHeight(); row++ ) {
			for ( int col = 0; col<field.getWidth(); col++ ) {
				field.place(row, col, new Cell());
			}
		}
		View view = new View(field);
		JFrame frame = new JFrame();
		frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
		frame.setResizable(false);
		frame.setTitle("Cells");
		frame.add(view);
		frame.pack();
		frame.setVisible(true);
	}

}

  非常重要的是,看Java代码,首先要学会看eclipse大纲

类:带字母C实心绿圆接口:带字母I实心紫圆变量:空心函数:实心
public:绿色圆private:红色框protected:黄色菱形缺省:蓝色三角
抽象:右上角A构造器:右上角C类(静态static):右上角S覆盖/实现:右下角小三角

  这里给出了部分大纲中的一些组成的特征,多看些大纲即可对应上。大纲可以很好地帮助我们快速建立整个程序的框架,梳理整个程序的逻辑。
  同样的,个人认为,在写程序时,应该先搭框架,不去管细节实现,把大纲先做出来:变量、方法体按最简单的形式(比如{ }或{return “”;}等)先做好,方法体中以注释的方式,用文字写明这个方法体要实现的内容。

  前面提到看程序的两种方法:

  1. 直接看主程序的main函数,想办法读懂main函数的每一句,看每一句、段的任务,提到的类,再去看那个类。
  2. 去看最小的类,找到一个很细节的类,然后去追它的父类,再看其他的小类,看懂每一个细节部件,再把整个图给拼起来。

  首先按第一种读程序的方法,先读主程序main函数大致理解代码:
Step1:新建一个 30 × 30 30\times30 30×30的Field;
Step2:一次行列的二重循环给每个位置放一个Cell;
Step3:再一次行列的二重循环,对每个位置的Cell初始化其alive属性;
  // 尽管2、3的代码可合并简化,但分开逻辑更清晰,且便于以后改动;到目前还在准备初始数据阶段
Step4:接下来做了一个View和JFrame(Java的图像窗口,而View可添加进JFrame),并添加了JFrame的基本属性;
  //这样就画出了一个静止的窗口,里面记录了初始时刻的细胞网格;此时再去看View类,里面核心的就一个覆盖Jpanel(Java图形库中表示一块画面)的paint函数,让网格里每个存在的Cell(二重循环)自己把自己draw()出来;再看Field,核心就是维持好一个二维数组。接下来是Field和View的互动
Step5:做100步(实际上最后结果一定会收敛到一种稳定情况不再变动),每一步再一个Field的二重循环,即找到Field的每个单元(Cell),建立一个三重循环框架:
  Step6:让每个Cell找到它的邻居(周围八个Cell),并计活细胞的数量;
  Step7:判断这一次是否继续存活、是否重生;
  Step8:print输出这一步这个网格的:位置+死/活+相邻活细胞数+变动;
  Step9:每个二重循环后print一句update,然后通过View的paint函数重画整个Field;
    //上面的循环体就是核心部分,此时再看Cell的部分,它的变量和函数。

  下面再按第二种读程序的方法,再次梳理CellMachine、Field和View的关系:

  • Cell是最小单元,主要是alive属性,和draw()把自己画出来,显然Cell不知道也不应该知道Field;
  • Field其实是个容器,核心是一个元素为Cell的二维数组,可以通过palce、get方法放进、得到Cell,neighbor方法找到相邻活细胞组成的一个Cell数组,Field是完全不知道View的
  • View并不去管Cell,View只认识Field,主要函数是paint,给一个Field画一个网格或棋盘;
  • CellMachine并不代表任何的东西,它实现的是业务逻辑,将数据Field(Cell是Field的单元)和表现View联系起来。

  回过头再看代码,我们是把整个Field更新好后,再去repaint。我们不是再每个单元去判断,不是变动一点数据就重画一部分。

  回顾上面的细胞自动机程序,有下面这些设计理念

1 数据与表现分离
  这样的设计理念下,表现的形式与数据、业务逻辑无关,可以更加丰富和多变,表现可以是图形的也可以是文本的,可以是当地的也可以是远程的。
  这里的View和Field,就体现了表现与数据分离的关系:View只管根据Field画出图形,Field只管数据的存放。一旦数据更新以后,通知View重新画出整个画面即可,不需要去精心设计哪个局部需要更新。
  这样大大简化了程序逻辑,这是在计算机运算速度提高的基础上实现的。现在的图形界面程序都是这个思路,整个图形界面重画反而当下是最经济最好的做法。

2 责任驱动的设计
  将程序要实现的功能分配到合适的类/对象中去,即合理的梳理和分割需要实现的功能,并分配到合适的类里去,每一个部分都只做自己最擅长的事情。比如这里的Field只存数据,View只做表现,CellMachine只负责业务逻辑。

3 网格化的表现形式
  图形界面本身有更高的解析度,但是将画面网格化以后,数据就更容易处理了。网格化能让整个画面的设计最简单。

  最后提几个细节:1. 我们没有由Cell自己判断自己的邻居的情况来决定自己是否应该被die或reborn,因为作为单元的Cell不应该知道Field的存在,或者说至少不应该知道周围邻居的情况,做好Cell自己的事就好,这样降低了Field和Cell的耦合。2. 我们没有让Field.getNeighbour()去根据Cell.isAlive()来返回一个数字,而是要返回一个数组让外面来数数,是因为a这其实是Cell和Field的业务逻辑部分,我们希望Field管好自己的数据就好,业务逻辑放在专门的CellMachine;b避免调用Cell中的函数,降低二者的耦合关系;c便于维护和扩展,以后实现其他功能时可能也会用到Cell的邻居。

6.3 接口:狐狸与兔子

  类似上面的细胞自动机,设计一个新的程序:狐狸和兔子。
  狐狸和兔子都有年龄;当年龄到了一定的上限就会自然死亡;狐狸可以随机决定在周围的兔子中吃一个;狐狸和兔子可以随机决定生一个小的,放在旁边的空的格子里;如果不吃也不生,狐狸和兔子可以随机决定向旁边空的格子移一步。
  可以看到,多了一些属性,比如年龄;以前网格只是细胞,现在变成了狐狸和兔子;多了一些规则。

  在新的程序中,Cell类的定位很尴尬:
  在细胞自动机中,Cell既是细胞(格子里的东西),也是格子,二者是同一的。而对于这里的Fox和Rabbit(格子里的东西),虽然也用Cell作为输出的表现,但并不是格子Cell,进一步也不能说是继承于Cell,因为Fox和Rabbit显然有一些共性,它们的父类应该是Animal。
  如果它们还继承于Cell,那就是多继承,而Java没有多继承。实际上除了C++以外的所有OOP语言都没有多继承,因为多继承从语言实现的角度来说,是不好实现的。
  也不能说它们的父类Animal继承自Cell,因为这会导致语义上的混乱,逻辑上的不协调,Animal和Cell是完全不相干的东西,凭什么只是因为此处需要,就可以就强加一种继承的关系——如果Animal继承自Cell,我们姑且还可以理解这里只有一层的强加关系,但这样的规则允许下去,那么必会导致复杂程序时的逻辑含糊,后面自己都看不懂,因为再复杂点就必然出现继承没有逻辑只是需要。

  所以可以看到,这里需要一种不同于继承/扩展/派生 extends - 父类/基类/超类 BaseClass/SuperClass的关系:
实现 接口implements Interface)。

  接口,Interface,是 纯抽象类,不需要abstract的修饰词,有了新的implements关键字

  • 所有的成员函数 都是 抽象函数
  • 所有的成员变量 都是 public static final,公有的类的常量。
  • 接口表达的是概念、规范,接口规定了长什么样,但是不管里面有什么。

  有了接口后,前面提到的Fox和Rabbit,都继承自Animal,Animal和Cell接口没有关系,而Fox和Rabbit类都实现了纯抽象类Cell接口。如:public class Fox extends Animal implements Cell { … },即Fox类继承了Animal,Fox实现了Cell。

  前面提到了,抽象类不可能有对象, 但抽象类可以有变量,该变量可以管理所有继承了该抽象类的非抽象子类的对象。在这里,类似的,对于纯抽象类的接口,接口不能有对象,但接口可以有变量,该变量可以管理和表示所有实现了接口的类(接口的实现类)的对象

PS:Java 中的instanceof 运算符是用来在运行时指出对象是否是特定类的一个实例。instanceof通过返回一个布尔值来指出,这个对象是否是这个特定类或者是它的子类的一个实例。

  程序是在前面细胞自动机的基础上改进的,这里就略去了,需要的话去GitHub上搜foxandrabbit。

  这里的Cell不再是class,而是public interface Cell { },interface取代了class的位置。在Java中,interface是一种特殊的class(专门设计的纯抽象类),它的地位和class一样,即能出现class的地方都可以出现interface,比如class/类可以用于定义变量、参数、函数返回值的类型,interface/接口就也可以。前面提到,接口变量用于管理和表示所有实现了接口的类(接口的实现类)的对象。而当一个方法的参数也是接口时,也即接口作为函数的参数进行传递时,必须传递进去一个接口的实现类的对象。类似的,接口作为函数的返回值进行传递时,必须返回一个接口的实现类的对象。

  类可以有多个接口(但只能继承自一个父类)。
  接口可以继承接口,但显然不能继承类(因为成员要求都是抽象的)。
  接口不能实现接口

  interface出现后,给Java带来了全新的面貌:面向接口的编程方式。
  接口,就是两种东西的连接。有了接口后,当需要别人提供某种服务时,当需要其他的某种东西时,不是去定义一个类出来,而是先定义一个接口,表示一种概念性的规范、定义。

  比如这里的Cell是由Field确定的,与Animal中的Fox和Rabbit无关,Field表示它只接受Cell这种规范的类,具体什么类不重要,但要满足Cell的规范,符合Cell的定义,即实现Cell这种接口。

  因此,这给Java设计时带来一种新的模式:设计程序时先定义接口,再实现类。任何需要在函数间传入传出的一定是接口不是具体的类

   面向 接口 的编程方式:
  优点:非常适合多人同时写一个大程序,这是Java成功的关键之一。每个人只需要提出对别人程序的要求,提出一个概念性的接口就好了,别人再去做那个接口。由接口把每个人的任务、每个部件间的关系分隔开。很多整个架构做的好的大型程序,可以看到大量的接口存在。
  缺点:代码量膨胀起来很快,这也是Java被诟病的要点之一。可能原本写一个类就够了,现在需要先写一个接口,再写一个类,甚至几个接口几个类。整个程序非常的臃肿。

  现在回到城堡游戏中的一个问题(5.5节):一个类调用其他类的函数。在城堡游戏中,解决方法是让每一个类有一个其他类的管理者(A类Handler知道B类Game),具体来说,就是添加了另一个类的成员变量,然后通过构造函数将其他类的对象赋给该成员变量,再由成员变量去调用其他类的函数。
  细胞自动机和狐狸兔子的Cell和Field中也存在这样的情况,这里我们采用了不同的解决方案:由外部的第三方来建立两者之间的联系(A类Cell不知道B类Field)。调用函数的相关内容是事务逻辑,放在专门处理事务逻辑的地方,Cell和Field各自只做自己的事情,提供自己的数据,不做相互交叉的东西。显然,这样降低了二者间的耦合,保证程序的可扩展性,遵从了数据和表现分离的原则,但需要借助第三方平台(额外的类)来完成业务逻辑,有时这样处理会增加一些工作量。

  最后注意一个细节,现在Cell接口其实是承担了两个责任的。在Field看过来,它只认识Cell,它认为Cell是存放在其中的数据。而对于View来说,它看到的是Cell所具有的draw方法,从这个角度来说Cell表示了表现。这里我们没有进一步将数据和表现相分离,比如用一个Cell抽象类表示可以放进Field的东西,再用另一个Drawable接口表示可以被Vier画出来的东西。这是因为这样会导致简单的问题复杂化,也就是上面接口导致代码量膨胀的问题。

第7章 控制反转与MVC模式

7.1 控制反转

  GUI(Graphical User Interface,图形用户界面)给应用程序提供界面,其中包括窗口、菜单、按钮和其他图形组件,这就是今天大多数人所熟悉的“典型”应用程序界面。图形用户界面所涉及的细节很多,这里不细讲GUI,但这里打算借助GUI来介绍两个设计思想:控制反转和MVC设计模式。

  部件是创建GUI的独立部分,比如像按钮、菜单、菜单项、选择框、滑动条、文本框等。Java类库中有不少现成的部件。

  布局是指如何在屏幕上放置组件。过去,大多数简单的GUI系统让程序员在二维坐标系上指定每个组件的x和y坐标(以像素点为单位),这对于现代的GUI系统来说太简单了。因为现代的GUI系统还得考虑不同的屏幕分辨率、不同的字体、用户可改变的窗口尺寸,以及许多其他使得布局困难的因素。所以需要有一种能更通用的指定布局的方法,比如:要求 “这个部件应该在那个部件的下面” 或者 “这个部件在窗口改变尺寸时能自动拉伸,但是其他部件保持尺寸不变”。这些可以通过布局管理器(layout manager)来实现。

  事件处理是用来响应用户输入的技术。创建了部件并且放在屏幕上合适的位置以后,就得要有办法来处理诸如用户点击按钮这样的事情。Java类库处理这类事情的模型是基于事件的。 如果用户激活了一个部件(比如,点击按钮或者选择菜单项),系统就会产生一个事件。应用程序可以收到关于这个事件的通知(以程序的一个方法被调用的方式),然后就可以采取程序该做的动作了。

  在前面的FoxAndRabbit程序中,我们试图在图形上加一个按钮,表示只运行一步图形的变换。
  这里我们采用Java的Swing机制来实现。
  Swing的意思是说,在图形中看到的所有东西都是 部件,还有一个概念是 容器容器里可以放各种部件,当然 容器本身也是一种部件,自然也有 容器可以放在另一个容器中关系类似于数学中的:集合 与 集族)。

  容器对部件的管理,有一个重要的地方在于管理部件的位置,显示的大小。容器管理的手段叫做布局管理器(Layout Manager)。对于窗体/框架/frame来说,默认采用的布局管理器是Border Layout。
  如果采用了BoardLayout的布局,那么就是把图形显示划分成5块部分,North、East、South、West、Center。当把部件加到BorderLayout的容器里去时,是需要指定加到哪块区域去的。如果不指定,那么默认的就是Center,所以加入不指定的两个部件时,后一个部件就会覆盖掉前一个。

	frame.add(theView);       //相当于 frame.add(theView, BorderLayout.CENTER);
	JButton btnStep = new JButton("单步");
	frame.add(btnStep, BorderLayout.NORTH);

  Layout manager是Java的Swing机制中一个蛮特殊的东西,因为在其他的GUI的平台中,GUI的类库、函数库当中,没有看到类似的手段。
  Layout Manager的好处是由底下的类库去自动计算部件的位置、显示大小,使得显示环境发生变化时(如分辨率发生变化等),能够保证相对位置的成立,和显示内容的完整。但不保证美观,一般也都不是最佳观感,只保证实用性。

  前面实现了在一个图形界面添加按钮,那么程序怎么知道运行时这个按钮按下去了呢?
  用户在图形界面上的操作传递给程序的路径(即用户的操作让程序知道的方法),我们称之为 “消息机制”。Java的Swing实现了一个很有意思的消息机制:事件监听器 /event Listener
  Swing使用一个非常灵活的模型来处理GUI的输入:采用事件监听器的事件处理(event handling)模型。
  Swing框架本身以及大部分部件 在发生一些情况时 会触发相关的事件,而其他的对象也许会对这些事件感兴趣。不同类型的动作会导致不同类型的事件:当点击一个按钮或选中一个菜单项,部件就会触发动作事件 Action Event;而当点击或移动鼠标时,会触发鼠标事件 Mouse Event;当框架被关闭或最小化时,会触发窗口事件 Window Event。另外还有许多种其他事件。

  所有的对象都可以成为任何这些事件的监听者(Listener,也有翻译成(添加)收听器、(成为)收听者),而一旦成为监听者,就可以得到这些事件触发的通知实现了众多监听器接口之一的对象就添加了相应的一个事件监听器。或者说,如果对象实现了恰当的、相应的接口,就可以注册到它想监听的组件上。

  这里是这样一个情况,早已写好的JButton类,它的对象要有能力可以调用刚写的类中的成员函数和变量。按照最基本的理解,如果JButton类的一个对象btnStep中有这个新类的变量,另一个函数中调用该新类变量的方法。但是JButton类是早就写好的,它显然不知道也不该知道新写的任意一个类。那么,一个事先写好的类的函数可以调用后来写出的类的成员,有什么办法来实现呢?

  容易联想到继承和函数的覆盖:写一个MyButton类来继承JButton,在MyButton中添加新类成员,覆盖JButton的函数。这是一种思路。

  Java Swing用了另一种更先进的方式:Listener接口,这里的点击按钮是动作事件收听者(动作监听器)ActionListener。
  JButton类有这样一对函数,addActionListener(ActionListener l)、removeActionListener(ActionListener l),参数是ActionListener类型的一个接口 I: java.awt.event.ActionListener

  而ActionListener接口就定义了一个事情,只有一个抽象函数actionPerformed(ActionEvent e),即动作执行了,参数是ActionEvent类型的一个类 C: java.awt.event.ActionEvent。实现了这个接口的类,自然也必须具体的实现这个函数。

  以Jbutton为例,整个过程如下:
  JButton给出监听器接口,即先写一个接口ActionListener,接口只有抽象函数actionPerformed(ActionEvent e)。
  再在JButton中基于接口(以接口变量为参数)写一对函数:addActionListener(ActionListener l)和removeActionListener(ActionListener l),表示注入、注销接口。注册进去的东西,是一个运行时刻动态的一个对象(多态)。

PS:如果JButton类的成员变量调用这个注入函数后,表示注入了该接口的实现类的对象,在代码中是JButton类中的成员ListenerList会add(ListenerList是另一个类,也有add函数)。具体来说很复杂,这里不深究。总的来说是,如果JButton类变量调用了add监听器函数,那么覆盖了接口参数(即实现了该接口的类的对象)的actionPerformed函数就会得到执行。

  一旦按钮按下去,就会检察一下有没有“人”注册在“我”这,有没有人对我被按下去这事感兴趣,如果有的话,那么注册上来的东西一定是实现了ActionListener接口的一个对象,而ActionListener是JButton发布的(类比FoxAndRabbit中Cell接口由Field决定,即Field发布了Cell接口),JButton当然知道ActionListener长什么样,知道它有个actionPerformed函数,然后JButton就可以反过来循着前面的路线调actionPerformed函数(而actionPerformed函数是内部类,其中可以调用新类的成员,也就完成了反转控制)。

  也就是说,JButton作为一个已经存在的类,它公布一个接口,任何人对它被按下去这件事感兴趣,只要去遵循、实现这个接口,把接口的实现类的对象交给、注册给JB u疼痛,也即注册一个实现了ActionListener接口的类的对象给JButton。JButton一旦被按下去,就会反过来找到你自己写的那个actionPerformed函数,然后把这个代码执行了,来执行你写(覆盖、实现)的那个actionPerformed函数。

  这件事情,就是反转控制(注入反转)。JButton中本来没有actionPerform的代码,通过注入的过程,加进去实现了控制,所以也叫注入反转,简单的说,就是Swing的消息机制。

具体的代码和伪代码如下:

新类{
	定义JButton类的成员变量并初始化; //这里JButton类表示旧类
	变量名.addActionListener(ActionListener l);
}

  进一步,上面的参数ActionListener l实际上表示任意实现了ActionListener接口的类的对象,那么关键点来了:
JButton类的变量的函数调用新类的函数,就是通过这个ActionListener l来实现,具体实现时,我们是通过匿名内部类来完成。

	btnStep.addActionListener(new ActionListener() {
			@Override
			public void actionPerformed(ActionEvent e) {
				step();
				frame.repaint();
			}
		});

  这个匿名内部类(的对象),可以不用匿名,分解成一个非匿名的内部类和新建该非匿名类的对象:

public class FoxAndRabbit {
	成员变量……
	
	public class MyActionListener implements ActionListener {
		@Override
		public void actionPerformed(ActionEvent e) {
			step();
			frame.repaint();
		}
	}

	btnStep.addActionListener(new  MyActionListener() );

可以看到上面就实现了JButton类的函数调用了新写的FoxAndRabbit类中的Step()函数访问了frame变量。

  再回顾一下,上面就是通过接口实现的反转控制 (注入反转):
在先写的类中,先给出接口(即提出需要的规范、特征),再基于接口写函数,即函数以接口为参数,传递实现了接口的类的对象,然后用这个接口的实现类的对象来调用后写的新类中的函数(接口的实现类是新类的内部类,可以直接访问和调用新类的成员, 覆盖接口中的抽象函数即可)。

  内部类就是指一个类定义在另一个类的内部,从而成为外部类的一个成员。内部可细分为类的内部和函数的内部,即外部可以是类或函数。因此一个类中可以有成员变量、方法,还可以有内部类。实际上Java的内部类可以被称为成员类内部类实际上是它所在类的成员。所以内部类也就具有和成员变量、成员方法相同的性质。比如,成员方法可以访问私有变量,那么成员类也可以访问私有变量了。也就是说,成员类中的成员方法都可以访问成员类所在类的私有变量。内部类最重要的特点就是能够访问外部类的所有成员(变量和方法)。

  这里通过new一个接口,然后接上{},{}中覆盖接口中的抽象方法,就实现了匿名类
  匿名类有两层含义,1这样快捷地做出了一个实现了接口的类,并通过new给出了具体的对象;2这个类是匿名的,这里省略了为这个接口的实现类命名这一步(当然不匿名也可以),这样使得问题大大简化。回顾一下:

  匿名类:

  • 在new对象的时候给出的类(接口是特殊的纯抽象类)的定义形成了匿名类;
  • 匿名类可以继承某类,也可以实现某接口;
  • 匿名类一定是内部类,Swing的消息机制广泛使用匿名类。
  • 匿名类是由Swing的消息机制引起的:每一个部件会发出的消息,就需要有一个新的类去实现一个新的方法,去接受这个消息。当程序里由很多按钮、菜单等时,每个菜单项和按钮一样都会发出actionPerformed消息,那每个菜单项都要为它设计一个类,然后为它写一个actionPerformed函数。如果没有匿名类机制的话,那么在这个过程中,我们要花很多的时间来为每个类命名,这是非常繁琐的。而有了匿名类后,有了实现接口的类(它由实现了的actionPerformed函数)并new出了相应对象,这个对象注入到相应控件中去,实现注入反转。

  内部类:

  • 定义在别的类内部、函数内部的类
  • 内部类能直接访问外部的全部资源
    • 包括任何私有的成员
    • 外部是函数时,可以访问函数所在类的所有成员(变量和函数);在访问函数的本地变量时,只能访问外部函数里final的本地变量

  注入反转:

  • 由按钮公布一个监听器/收听者接口和一对注册/注销函数
  • 代码实现那个接口,将收听者对象注册在按钮上
  • 一旦按钮被按下,就会反过来调用你的收听者对象(对应这里的匿名内部类)的某个函数(因为是函数内部类,可以访问函数所在的外部类的所有成员)

7.2 JTable,MVC模式

  先根据JTable做一个简易课程表的程序,代码如下:
KCB,课程表类

package kcb;

import javax.swing.JFrame;
import javax.swing.JScrollPane;
import javax.swing.JTable;

public class KCB {
	public static void main(String[] args) {
		JFrame frame = new JFrame();
		JTable table = new JTable(new KCBData());
		JScrollPane pane = new JScrollPane(table);   //滚动窗格,将Table放在pane中,使所有内容呈现
		frame.add(pane);
		frame.pack();
		frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
		frame.setVisible(true);
	}
}

KCBData

package kcb;

import javax.swing.event.TableModelListener;
import javax.swing.table.TableModel;

public class KCBData implements TableModel {
	private String[] title = { "周一","周二","周三","周四","周五","周六","周日" };
	private String[][] data = new String[12][7];
	public KCBData() {
		for (int i = 0; i < data.length; i++) {
			for (int j = 0; j < data[i].length; j++) {
				data[i][j] = "";
			}
		}
	}

	@Override
	public int getRowCount() { return 12; }

	@Override
	public int getColumnCount() { return 7; }

	@Override
	public String getColumnName(int columnIndex) { return title[columnIndex]; }

	@Override
	public Class<?> getColumnClass(int columnIndex) {
		return String.class;   // 返回每列是什么类
	}

	@Override
	public boolean isCellEditable(int rowIndex, int columnIndex) { return true;	}

	@Override
	public Object getValueAt(int rowIndex, int columnIndex) {
		return data[rowIndex][columnIndex];
	}

	@Override
	public void setValueAt(Object aValue, int rowIndex, int columnIndex) {
		data[rowIndex][columnIndex] = (String)aValue;
	}

	@Override
	public void addTableModelListener(TableModelListener l) {}

	@Override
	public void removeTableModelListener(TableModelListener l) {}

}

  JTable类可以用表格的形式来显示和编辑数据。但注意到,JTable类的对象并不存储数据,它只是数据的表现。它符合我们前面提到过的一个很重要的设计原则:数据要和表现相分离。JTable这个类只是用来管可以看见的那个视图的,只是表现,而数据放在对应的TableModel里面。

Table、TableModel、数据、表现的关系示意图
Table、TableModel、数据、表现的关系示意图

  我们看见的表格,是一个JTable的对象(JTable提供TableModel接口),在构造JTable对象的时候,给了它一个TableModel接口的对象(即实现该接口的类的对象)。在TableModel里面必须去覆盖所要求的那么多的函数,这些TableModel的函数在JTable被显示、操作的过程中,会被相应的调用的,即JTable运行过程中就会回过来调TableModel的函数,比如说JTable决定往里面送一个值时会调用TableModel的函数setValueAt(Object aValue, int rowIndex, int columnIndex)。这里是前面提到的反转控制。
  另一方面,你的数据是由自己写的那个TableModel来维护(实现那个接口),而JTable只管表现不管数据。事实上,这件事还要往前再推一步,和之前做Fox&Rabbit、CellMachine不一样的地方是,现在我们在用户界面上对Table本身是有控制的,之前是数据、表现相分离,现在增加了一个内容:控制。

  数据、表现和控制 三者分离,各负其责

  • M= Model 模型:保存和维护数据,提供接口让外部修改数据,通知表现V需要刷新
  • V = View 表现︰从模型M获得数据,根据数据画出表现
  • C= Control 控制:从用户得到输入,根据输入(通过M提供的接口)调整数据

  这就是MVC设计模式,其中很关键的一点是,Control和View没有任何联系,用户在界面上做出的任何操作,不直接修改界面上的显示,而是输入调整数据,内部数据告诉View拿所有的数据然后全部重画一遍,不要去考虑每一个调整的细节。

  在这个课程表程序中,模型M是TableModel,表现V 包含于 JTable,同时控制C也 包含于 JTable。

  即Control和View在这个框架体系中,合并成了一个东西。这是一种常见的处理方式,和MVC分离并不矛盾,合并的是程序,分离的是实质上的关系。控制(或者说得到用户的输入)本来就是在图形界面上实现的,所以在表达图形界面的类里面,它做了表现View,同时也由他得到了用户的输入、来控制Model。这并不矛盾,只是把Control和View合并起来了。

第8章 异常处理

8.1 异常

  先看一个早就见过的异常:

package exception;
public class MyArrayIndex {
	public static void main(String[] args) {
		int[] a = new int[10];
		a[10]= 10;
		System.out.println("hello");
	}
}

  上面的下标越界的错误在编译器中不会检测出,运行时控制台会得到红色的提示如下:

Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: Index 10 out of bounds for length 10
	at myException.MyArrayIndex.main(MyArrayIndex.java:5)

  即:线程“main”出现异常, 是数组下标越界异常:下标10超出了长度10的范围, 位置在myException包中的MyArrayIndex类的main函数(MyArrayIndex.java文件的第5行)。

  注意到,这里第5行出异常后,第6行的代码System.out.println(“hello”);没有执行。

  如果程序里有(或可能有)异常,下面学习怎么以恰当的方式处理异常。首先是怎么知道异常。

  用try{ }圈出可能出异常的代码块,然后加上catch{ }执行如果出异常的操作。代码修改如下

package myException;
import java.util.Scanner;
public class MyArrayIndex {
	public static void main(String[] args) {
		int[] a = new int[10];
		Scanner input = new Scanner(System.in);
		int idx = input.nextInt();
		try {
			a[idx] = 10;
			System.out.println("hello");
		} catch (ArrayIndexOutOfBoundsException e) {
			System.out.println("no hello");
		}
	}
}

  上面运行输入5,下标不越界得到结果hello,运行输入10,下标越界得到no hello。
  但此时的结果不再有红色字体,即程序是正常运行的。
  no hello的输出,表明这里try中出现异常的语句后面不再执行,而是直接转到了try{ }后面相对应异常的catch位置(可以有多个catch异常,这里只有一个):catch (ArrayIndexOutOfBoundsException e),并执行这后面的{ }中的语句。

  这就是Java的异常处理机制,我们把可能出现异常的东西,放在try{ }里面;然后在try后面对应的catch( ) { }里面,去处理所有可能的异常。catch()的{ }处理完以后,我们会在catch( ) { }后继续往下走,而不会返回异常的语句处往下走,即不会返回执行try{ }中异常语句后被落下的事情。

  所以,try-catch是这样用的:
try {
 //可能产生异常的代码
} catch ( Type1 id1 ) {
 //处理Typel异常的代码
}catch ( Type2 id2 ){
 //处理Type2异常的代码
}catch ( Type3 id3 ) {
 //处理Type3异常的代码
}
  可以看到,可能产生异常的代码放在try的{ }中,后面可以跟上一系列的catch,以处理代码块中不同的异常。

  上面的异常是比较简单直接的形式,代码块中的语句本身有异常,略过代码块中异常语句后的语句,执行所匹配catch中{ }的语句。如果是代码中的函数有异常,那么情况会相对复杂,如下图所示:

此处放图片
异常捕捉示意图

  当有异常抛出时,我们可以遵循这张图,来判断我们该在什么地方捕捉这个异常。下面构造一些程序来测试一下。测试异常捕捉的代码如下:

package myException;
public class MyArrayIndex {
	
	public static void f() {
		int[] a = new int[10];
		a[10] = 10;
		System.out.println("f函数异常语句后一句");
	}
	
	public static void g() { f(); }
	
	public static void h() {
		int i = 10;
		if (i<11) { g(); }
	}
	
	public static void k() {
		try {
			h();
		} catch (NullPointerException e) {
			//NullPointerException 即空指针异常,null表示没有这个对象,
			//既然没有这个对象,那么去调用他的属性和方法,就会报异常,
			//一般是未初始化引起的。显然不是这里对应的异常。
			System.out.println("k函数的非对应catch语句");
		}
//		} catch (ArrayIndexOutOfBoundsException e) {
//			System.out.println("k函数捕捉到异常然后又抛出了");
//			throw e;
//		}
	}
	
	public static void main(String[] args) {
		try {
			k();
		} catch (ArrayIndexOutOfBoundsException e) {
			System.out.println("caught\n");
			System.out.println(e.getMessage()+"\n");
			System.out.println(e+"\n");
			e.printStackTrace();
		}
		System.out.println("main函数中try后的语句……");
	}
}

运行结果

caught

Index 10 out of bounds for length 10

java.lang.ArrayIndexOutOfBoundsException: Index 10 out of bounds for length 10

java.lang.ArrayIndexOutOfBoundsException: Index 10 out of bounds for length 10
	at myException.MyArrayIndex.f(MyArrayIndex.java:6)
	at myException.MyArrayIndex.g(MyArrayIndex.java:10)
	at myException.MyArrayIndex.h(MyArrayIndex.java:14)
	at myException.MyArrayIndex.k(MyArrayIndex.java:19)
	at myException.MyArrayIndex.main(MyArrayIndex.java:34)
main函数中try后的语句……

  梳理一下,main函数中用try调用k函数——且catch到了相应异常,k函数是用try调用h函数——但未catch到相应异常,h函数用if语句(即不是函数)调用了g函数,g函数直接调用了存在异常的f函数。
  那么:以f函数中异常语句开始分析:1语句出现“数组下标越界”异常,语句所处的不是try,是函数f;2返回调用f函数的{ },f()所处不是try,是函数g;3返回调用g函数的{ },g()所处的不是try,不是函数,而是if语句块(相当于没有try、函数的直接语句调用);4返回调用if语句块的{ },是函数h;5返回调用h函数的{ },是try,但是没catch到对应的异常,相当于没有try、函数的直接语句调用;5返回调用未catch到异常的try语句块的{ },是函数k;6返回调用k函数的{ },是try,且catch到了,则执行catch后的{ };7再执行try语句块后的语句。

  那么,捕捉到异常可以做什么?

  可以看到,try{ }后的catch (异常类型 变量) { },就像一个void的catch函数,它本身可以结合异常这个参数(调用异常对象的函数)来做些事情。

PS:Java中的 异常 本身是一种类,如(ArrayIndexOutOfBoundsException <)IndexOutOfBoundsException、NullPointerException< RuntimeException运行时刻异常 < Exception < Throwable,其中<表示extends。

比如拿到异常对象之后,常见的有String getMessage(); ,String toString(); ,void printStackTrace(); 等。但是肯定是回不去了,即不会再回到try { }中执行异常语句后的部分,而是catch { }执行完,然后try-catch就完了,程序接着往后运行。具体的处理逻辑则取决于你的业务逻辑需要。

  String getMessage(); 返回这个可抛出对象的详细消息字符串,如此处的数组下标越界异常Index 10 out of bounds for length 10。

  String toString(); 返回这个异常类的全名+ : +getMessage(),如java.lang.ArrayIndexOutOfBoundsException: Index 10 out of bounds for length 10。

  void printStackTrace(); 先返回toString(),然后由内到外逐步返回调用了异常的位置。stack是堆栈,函数调用过程中怎么一步步调用到发生异常的地方(反过来看,如这里的main调了k,k调了h,h调g,g调f),就是调用堆栈。利用printStackTrace这一手段,我们可以知道异常到底在哪发生的,中间的调用轨迹是怎么样的。这样一层层的看上去,有时可以猜测出错误的原因。

  此外,捕捉异常后,还可以再次 抛出异常。 catch ( 异常类型 参数e) { throw e; }
  比如需要在这个层面上处理,但是不能做最终的决定,即当前只能、只需要处理部分异常。

  再度抛出是捕捉到了对应异常类型的异常,然后再次抛出,使得这里的try-catch语句块仍然是异常的、有错的,相当于没有处理,然后继续异常捕捉。如果没有catch{ }中没有throw e; ,则异常捕捉到后就认为catch{ }部分处理掉了。

8.2 异常机制

  try-catch机制可以理解、整理为if-else:如果业务逻辑正常则执行try,else执行对应的catch。但是显然又有不同:try中是一系列有时间顺序的业务逻辑,可以有多个异常,后面依次catch处理。

  比如,设计一个程序,做到这样的内容:1打开文件,2判断文件大小,3分配足够的内存空间,4把文件读入内存,5关闭文件。
  后面每一步依赖前一步的正常运行,每一步都可能出错。这时候如果写作if-else形式,那么会非常复杂,1可读性差,2可维护性差,新增一个可能出现的问题,意味着插入新的一个if-else,就目前的代码就已经容易插错位置了。对比try-catch的异常处理机制如下:

在这里插入图片描述在这里插入图片描述

  可以看到,在try-catch中我们把业务逻辑放一起,连贯的表达;后面用一系列的catch来解决try中的问题。如果发现了新的问题,在try-catch中是非常容易定位需要插入的位置的。
  比如打开文件异常,catch中弹出没找到文件的对话框,选择改变文件名再试一次则在try-catch外加一个while循环,选择退出就break。这里意在说明的是,发现异常处理一次就完了,如果还要继续执行try-catch内的代码,即继续try中的业务逻辑(直至完全正确或满意),那么需要依靠try-catch外的循环来实现。

  这就是我们的异常机制。异常,即是出现非常规、不期望的事情,当这个事情发生的时候,原本打算要接着做的事情不能再继续了,必须得要停下来,让其他地方的某段代码来处理。异常机制最大的好处,就是清晰地分开了正常的业务逻辑代码(try { })和遇到异常情况时的处理代码(一系列的catch () { }),每一部分都清爽简洁。

  前面提到了catch中可以用throw关键字抛出异常,那么在函数 中也是用throw来抛异常。

  但不同的是,需要在函数头部声明这是个异常,类似类的继承时用throws的陈述形式来声明,throws出具体的异常类型(系统的异常,如数组下标越界、空指针等,不需要声明)。

  此外,异常本身是类,函数体中throw 的是一个异常类型的对象,即我们如果定义了自己的异常,前面没构建相应的对象时,函数中要throw new MyException();,即要new出这个异常的对象。

  再有,throw的都是Throwable类及其子类的对象,throw的不都是异常,异常都继承自Exception,Exception继承自Throwable。Exception类有两种构造函数,不带任何参数 和 参数为字符串。一般自定义的异常类也都有这样两种构造方式,可以用这个字符串去表达一些东西。
throw new Exception();
throw new Exception(“HELP”);

  如果你的函数可能抛出一个或多个异常,就必须在函数头部加以声明,如:
void f( ) throws TooBig, TooSmall, DivZero { … },这里TooBig, TooSmall, DivZero都是自己定义的异常类。

  你可以声明并不会真的抛出的异常(但需要定义好,只是目前函数不会抛出该异常),留作以后的拓展等。相对应的,即使实际上并不抛出该异常,但后面调用该函数时,try catch中依然要catch这些不会出现但声明了的异常。

  异常捕捉时的匹配:
  当我们做的异常比较复杂时,catch的匹配不是一种绝对的匹配,而是一种is-a的关系,就是说,抛出子类异常会被捕捉父类异常的catch给catch到

  当两种catch都存在时,只能是子类异常在父类前,因为在catch时认为子类包含于父类,也就是父类异常的catch在前,那么子类异常永远也catch不到,因此系统也会报错;反之,子类异常catch在前,则该catch只能catch子类异常(子类异常也只会被该catch捕获,不会被后面的父类异常catch捕获),父类的交给后面的父类catch。

  前面提到,在Java的系统类库当中,所有的异常都继承自Exception(Exception再继承自Throwable,Throwable里有些公共的方法)。因此,如有想有个catch捕捉任何的异常,那么只需要
catch (Exception e) { }就可以了。

  比如将catch (Exception e) { System.out.println(“Caught an unexpected exception”); }放在最后,那么前面catch中没有捕捉到(捕捉到执行了catch就退出整个try-catch,也不会到这一步)的任何异常(包含、系统的异常如空指针等)都会被捕捉,并打印Caught an unexpected exception。此时程序也是正常结束的,即使前面是数组下标越界等系统异常。

  运行时刻异常,RuntimeException如IndexOutOfBoundsException、NullPointerException等,是不需要声明的。但是如果没有适当的机制来捕捉,就会最终导致程序终止(但还是会printStackTrace();供我们来debug)。

  前面的内容说明,如果调用一个声明会抛出异常的函数,那么必须:

  • surround with try/catch:把函数的调用放在try块中,并设置catch来捕捉所有可能抛出的异常;
  • 或,add throws declaration:函数头部声明自己会throws无法处理的异常,函数体中throw异常(的对象)。

  最后,当 异常声明遇到继承关系
  当覆盖一个成员函数的时候,子类不能声明抛出比父类的版本更多的异常,小于等于;
  而在子类的构造函数中,需至少声明父类可能抛出的全部异常(还可以包含其他新的异常),大于等于。

  先理解子类的成员函数覆盖父类的时,子类成员函数不能抛出比父类多的异常:
  对于一个声明了抛出异常的函数,系统在try/catch时,只会处理声明中的异常,比如只声明了数组下标越界的函数出现了空指针异常,那么这个异常不会被catch到,程序会终止。
  前面学习了向上造型(把子类的对象赋给父类的变量,向上造型总是可以的、安全的),对象的静态类型是父类,动态类型是子类。因此在调成员函数时,该(子类型的)对象被系统视为父类型,系统只能够处理前面(父类中)声明的异常。覆盖的成员函数可以没有异常或异常比父类中的少,但如果抛出了新的异常,而系统只知道这是父类型的对象,已经声明过的成员函数只能处理父类中抛出的异常类型(系统也不可能知道未来某个子类,突然抛出什么新的异常,不加规范就没法做了),那么这个子类中新抛出的异常就不能被catch到,程序就会出错终止。
  小结一下:向上造型中,是子类覆盖的成员函数不能抛出新的异常,否则在子类对象向上造型被视为父类时,系统不认识也就无法处理新异常。

  再理解子类的构造函数中,必须(且至少)声明父类可能抛出的全部异常,虽然在构造器抛异常是个不好的事情:
  首先回顾子类的构造函数:1.在构造一个子类的对象时,父类的构造方法也是会被调用的,而且父类的构造方法在子类的构造方法之前被调用。2.super()表示调用父类的不带参数的构造函数,同理super(成员变量)表示调用带这个(些)参数的构造函数。3.子类中的构造函数如果不需要调用带参数的父类构造函数,即子类构造函数第一行是super(),则可以省略,会隐式/implicit调用,即没有选用带参数的父类构造函数时会默认调用不带任何参数的构造函数super()。
  啰嗦了这么多,即说明子类的构造函数会调用父类的构造函数,父类构造函数抛出了那么多异常,子类构造函数抛出的异常显然包含父类构造函数中的所有异常

  还有一个问题,为啥子类构造函数异常可以比父类的异常多,向上造型怎么办?前面说过的话转眼就忘了?
  在构造函数时,构造函数本身就会抛异常,所以和成员函数不同的关键点来了:当构造函数抛异常时,构造函数本身会被放在try{ }语句块中,而之前成员函数抛异常时(一般而言构造函数不抛异常)构造函数通常在try{ }语句块以外。
  所以,回到问题的核心,之前要求子类覆盖函数异常不多于父类,是为了保证try/catch能够认出所有的异常并处理。那么,此时构造函数在try{ }中,系统就知道父类构造函数、子类构造函数、成员函数中抛出的所有异常,自然能够识别所有给出的异常并相应的catch。

other 输入输出:流、文本输入输出、流的应用

  估计用不到,不学了,略~

  • 1
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值