Trick and Magic(OO博客第二弹)
代码是设计,不是简单的陈述。而设计不仅要求功能的正确性,更注重设计风格和模式。
真正可以投入应用的程序设计,不是那种无脑的“黑箱”,超巨大的数组,多重循环暴力搜索,成吨全局变量……事实上,在实际应用中更重要的是权衡兼顾功能,性能,可读性,鲁棒性等等方面,而最终完成一个综合的工程。我们真正做的事是程序设计,而不是无脑地写代码。
在本次OO三次作业的周期里,我逐渐开始接触了多线程并发程序和真正工程化的模板设计,历经疯狂Google--爆肝写码--艰难读码的过程后,我从中发现了许多许多非常tricky的东西,希望能把它们总结出来并与大家分享。
(1)从增加逻辑到增加数据
当你遇到一系列功能性的名词时,你会怎么办,例如IFTTT作业中的四种触发器:modified,renamed,size-changed,path-changed。我想很多人的第一反应是下面这样:
/* * str代表从输入中获取到的字符串,数字对应各种触发器类型 */ if(str.equals("RENAMED") return 0; else if(str.equals("MODIFIED") return 1; else if(str.equals("SIZE-CHANGED") return 2; else if(str.equals("PATH-CHANGED") return 3; else return -1;
if-else加数字变量,简单粗暴,迅速解决,但是当整个程序变的很大时,或者多个人合作完成工程任务时,这样的方法会导致可读性和协作性非常差。
于是乎,稍微动动脑,又出现了另外一种改进版:
/* * str代表从输入中获取到的字符串,四个变量均初始化为false; */ if(str.equals("RENAMED") renamed=true; else if(str.equals("MODIFIED") modified=true; else if(str.equals("SIZE-CHANGED") size-changed=true; else if(str.equals("PATH-CHANGED") path-changed=true; else throw new XXXException(str);
这种做法初始化四个boolean类型的变量,然后在if-else中对应改变他们的值,四个变量使用实际意义命名,达到了不错的效果。
但是更往深层次想一想,假如我每个if分支里不单单是一条语句,或者有新的触发器增加,即增加需求,那这种方法需要增加一个变量定义,增加一路分支,再去完善新分支中的内容。这样每次都增加逻辑分支,有没有更好的方式呢?
当然是有的。不过首先,我们要明确一下程序中添加逻辑和添加数据的区别。添加逻辑和数据的方式是不一样的,成本更是不一样的。用一句话总结,就是添加数据是非常简单,低成本和低风险的,而添加数据是复杂,高成本和高风险的。下面是一个添加数据方法,即表格驱动法的例子,我使用出租车作业中出租车的四种状态为例:
package enums;
import java.util.HashMap; import java.util.Map;
//该作业中关于出租车状态的输入输出都是以数字形式,所以这里的键值使用数字字符串。 public enum TaxiStatus { SERVICE, ORDER, WAIT, STOP; private static Map<String,TaxiStatus> taxi_map=new HashMap<>(); public static void initialize(){ taxi_map.put("0",TaxiStatus.STOP); taxi_map.put("1",TaxiStatus.SERVICE); taxi_map.put("2",TaxiStatus.WAIT); taxi_map.put("3",TaxiStatus.ORDER); } public static TaxiStatus getValueOf(String str){ return taxi_map.get(str); } public static boolean inMap(String str){ return taxi_map.containsKey(str); } }
通过使用枚举enum和Map将输入形式和枚举类型映射起来,建立了一一对应关系。每次在判断输入信息时调用inMap和getValueOf方法即可,如为false则说明输入不合法,抛出相应异常,如果输入正确则返回对应的枚举名。每次新增需求,仅仅需要增加枚举类型中的数据和映射关系即可。
if(inMap(str)) return TaxiStatus.getValueOf(str); else throw new XXXException;
注:由于本人java萌新,所以对于enum和map的使用还比较初级,所以表达的可能比较复杂。对于表格驱动法,最简单的例子就是字典。
从添加逻辑到添加数据的优点如下:
1.将代码中的数据部分和逻辑部分分割开来,使整个程序设计一目了然。
2.对于需求更新甚至是全新的需求有着非常强的适应能力,每次仅需修改数据部分。
3.测试时,只要数据正确就不用测试程序本身的正确性,而添加逻辑必须得再进行测试。
4.添加数据法,或者说类似这样的代码可以重用于各种各样的场景下,而逻辑只能用于其所处的具体语境下,换句话说就是写死在程序中。
5.如果是在大型系统中,添加数据仅仅需要任意人员填写一个表单请求将新的数据加入数据库即可,而逻辑修改必须需要专门的开发人员来处理。
6.添加数据的方式强制限定了代码的风格,任何人添加数据必须遵守已经定义在数据存储容器中的模式,而添加逻辑有很多可以自定义的空间,在多人合作时容易产生问题。
(2)输入处理时的小魔法(正则技巧和自定义异常类)
谈起输入处理,正则表达式就是必不可少的一环了。主流的处理方法有两类:
1. group法
String regex = "(IF) (.+) (renamed|modified|path-changed|size-changed)" + " (THEN) (recover|record-summary|record-detail)"; Matcher matcher = Pattern.compile(regex).matcher(s); //中间省略错误处理 path = matcher.group(2); trigger = Trigger.parse(matcher.group(3)); task = Task.parse(matcher.group(5));
2.spilt法
if(input_line.matches(regex)){ String[] part=input_line.split("[|]"); String filename=part[1]; String trigger=part[2]; String mission=part[4]; }
这两种方法有一个尴尬的地方就是只有开发者在写代码的当天知道数组下标1,2,4或者group参数2,3,5的代表含义,一旦时间过去很久或是输入格式变化,再次进行修改更新就很麻烦。而事实上正则表达式中有一种给每个匹配部分打上“标签”的方法,通过这种方法将实际含义作为标签,瞬间解决了相关问题。
String INPUT_FORMAT= "\\[(?<id>.*),(?<src>\\(\\d+,\\d+\\)),(?<dst>\\(\\d+,\\d+\\))\\]"; Pattern INPUT_PATTERN=Pattern.compile(INPUT_FORMAT); Matcher mc=INPUT_PATTERN.matcher(input); if(mc.find()){ String identifier=mc.group("id"); String src_str=mc.group("src"); String dst_str=mc.group("dst"); }
这段代码的关键点即在正则表达式中使用"(?<标签名>匹配内容)"这样的格式来进行匹配,而对应group的下标就是各个标签名,这样的对应一目了然,也易于添加和修改。
输入处理部分,是一个难度不大但是情况复杂的部分。由于需要判断的情况很多,对于每一种不合法的输入情况又得有专门的处理,所以稍不注意就会形成好几层循环嵌套分支的局面,看起来十分复杂,在作业初期一个inputHandler方法写到七八十行是常有的事,很多人都会陷入如下模式:
if(condition1) do something if(condition2) do something //省略各种情况 if(condition2333) do something
现在,就要隆重推出我们的异常类大法了!!!首先是我出租车作业的异常类继承层次图:
首先,在每个异常类中,定义每种情况对应的处理方式和信息反馈,例如:
package exceptions; public class SameSrcDstException extends InputFailedException{ public SameSrcDstException(String src,String dst,double time){ super(String.format("#Same Src and Dst:%s %s %f",src,dst,time)); } }
其实,在输入内容的具体解析方法中,根据不同的情况,抛出对应的异常:
public static TaxiRequest inputParse(String input,double time)throws InputFailedException { input=InputHelper.removeSpace(input); Matcher mc=INPUT_PATTERN.matcher(input); if(mc.find()){ String identifier=mc.group("id"); String src_str=mc.group("src"); String dst_str=mc.group("dst"); String[] src_spilt=src_str.split(SPILT_FORMAT); String[] dst_spilt=dst_str.split(SPILT_FORMAT); int src_x=Integer.parseInt(src_spilt[1]); int src_y=Integer.parseInt(src_spilt[2]); int dst_x=Integer.parseInt(dst_spilt[1]); int dst_y=Integer.parseInt(dst_spilt[2]); if(rangeJudge(src_x,src_y)){ throw new OutLocationException(src_str,time); } if(rangeJudge(dst_x,dst_y)){ throw new OutLocationException(dst_str,time); } if(sameSrcDst(src_x,src_y,dst_x,dst_y)){ throw new SameSrcDstException(src_str,dst_str,time); } return new TaxiRequest(identifier,new Point(src_x,src_y), new Point(dst_x,dst_y),time); } else{ throw new InvalidInputContent(input,time); } }
最后,在输入处理的全局范围中,使用try-catch进行捕捉,调用相关异常类的方法进行处理,一套清晰完整高效的输入处理流程就构建起来了。
private void inputRequest(){ Scanner input=new Scanner(System.in); while(input.hasNext()){ String input_line=input.nextLine(); if(InputHelper.isEND(input_line)) break; try{ TaxiRequest request=InputHelper.inputParse(input_line,time); quene.add(request,start_time); }catch (InputFailedException e){ System.out.println(String.format("#Failed:%s",e.getMessage())); } } input.close(); }
(3)线程安全的迷惑点
关于线程安全,每个人都有这样一个问题,哪些类是线程安全的?而我可以不负责任的告诉你,所有类都不是线程安全的!!!
相信很多人都发现了作为队列用容器ArrayList和Vector的问题,其中Vector一般被称作线程安全类,这是为什么呢,通过对比两者的源码,原因很明显,就是一个synchronized的问题,Vector中可能会产生线程安全问题的方法都加了synchronized进行修饰,而ArrayList则不然:
public synchronized int size() { return elementCount; }
public int size() { return size; }
//前者是Vector中实现而后者是ArrayList中实现
但是,切不可简单的认为只要使用Vector就万事无忧了。这里的线程安全只是指Vector类实例化的对象的单个方法本身是线程安全的,但如果一个类中的方法A调用了所谓线程安全中的类的多个方法,并且A没有synchronized修饰,那么该方法A如果被多个线程重入,是仍然会产生线程安全问题的。实例如下:
Object value = map.get(key); if(value == null) { value = new Object(); map.put(key,value); } return value;
map是一个线程安全类的对象,但是在该方法片段中get和put方法之间的部分,是有可能发生当前线程时间片结束,而另一个线程获得时间片进入该段代码执行的,从而造成一个key可能对应两个value这样的问题,所以是线程不安全的。而解决的方法就是对这段代码整体加synchronized。由此可见,在这种意义上,没有真正线程安全的类,我们必须依靠手中的synchronized,在合适的位置对具体的代码片段进行保护,线程安全类只是给我们提供一个小单元的线程安全。
(4)三次作业的心路历程
这三次多线程大冒险是非常曲折的,具体的失败经历就一笔带过了。其中多线程的控制问题一直***难着我,在初始接触的时候,我一直纠结的一个问题就是:为什么提供的方法中不能确定地让某个线程休息,让某个线程工作,同时notify方法每次通知的又是哪个线程,究竟线程的执行顺序是怎样的?为什么不采用将所有线程固定好顺序分享时间片来执行。经过这三次作业之后,我目前的答案是:
1.线程执行的顺序不一定可以遵循一种固定的模式,例如网页请求的处理,请求的发送时间是随机的,为了做到及时响应,我们必须在输入到达的时候就做到及时响应,完成线程切换。
2.有一些情况根本不用考虑线程执行的先后顺序,无论什么样的顺序程序也能正常执行,所以不需要多费力去调整顺序。
3.可以通过外部定义相关条件判断来构造“有序”。如经典生产者消费者问题中的变量empty和full,记录产品的队列的事实情况从而有序的控制生产者和消费者之间的顺序。
多线程程序,最重要的就是共享对象,正是由于共享对象的存在才导致了多线程相关的问题。若没有共享对象,多线程程序只是几个同时执行的单线程罢了,没有什么两样。在设计的阶段,定义怎样的共享对象类,能够有效的在线程之间传递信息,是首要问题。接着,对于具体的方法,判断是否有对共享对象产生影响的行为,如果是,就要做相应的保护和同步,这是根据类的设计来对应处理的。解决好了这两个问题多线程编程中多线程的部分也就基本OK了。
在公测和互测中的各类BUG,要么是对于某些特殊情况缺乏考虑,要么是功能变多后代码之间的互相影响。而在构建完整个工程后再debug是非常痛苦的,不光找bug痛苦,改bug更痛苦,牵一发而动全身,你永远不知道改了当前的错误会不会产生新的错误。由此看来,代码的整体框架设计,可读性,可扩展性真的是基石一般的存在。好的代码风格和习惯,会一点一点给那些坚持好习惯的人带来惊喜。
最后,非常感谢这几次作业结束后分享给我代码的同学和互测遇到的同学!!!读代码真的是一件收益良多的事情。
当然特别特别感谢HansBug,他的工程模板堪称业界良心,最后附上模板GitHub链接 https://github.com/HansBug/java-project-template