前端设计模式之开闭原则(OCP)

译注:本文作者愤怒的韭菜

  定义:
  一个软件实体如类、模块和函数应该对扩展开放,对修改关闭。

  问题由来:
  在软件的生命周期内,因为变化、升级和维护等原因需要对软件原有代码进行修改时,可能会给旧代码中引入错误,也可能会使我们不得不对整个功能进行重构,并且需要原有代码经过重新测试。

  解决方案:
  当软件需要变化时,尽量通过扩展软件实体的行为来实现变化,而不是通过修改已有的代码来实现变化。

  开闭原则是面向对象设计中最基础的设计原则,它指导我们如何建立稳定灵活的系统。开闭原则可能是设计模式六项原则中定义最模糊的一个了,它只告诉我们对扩展开放,对修改关闭,可是到底如何才能做到对扩展开放,对修改关闭,并没有明确的告诉我们。以前,如果有人告诉我“你进行设计的时候一定要遵守开闭原则”,我会觉的他什么都没说,但貌似又什么都说了。因为开闭原则真的太虚了。

  在仔细思考以及仔细阅读很多设计模式的文章后,终于对开闭原则有了一点认识。其实,我们遵循设计模式前面5大原则,以及使用23种设计模式的目的就是遵循开闭原则。也就是说,只要我们对前面5项原则遵守的好了,设计出的软件自然是符合开闭原则的,这个开闭原则更像是前面五项原则遵守程度的“平均得分”,前面5项原则遵守的好,平均分自然就高,说明软件设计开闭原则遵守的好;如果前面5项原则遵守的不好,则说明开闭原则遵守的不好。

  其实笔者认为,开闭原则无非就是想表达这样一层意思:用抽象构建框架,用实现扩展细节。因为抽象灵活性好,适应性广,只要抽象的合理,可以基本保持软件架构的稳定。而软件中易变的细节,我们用从抽象派生的实现类来进行扩展,当软件需要发生变化时,我们只需要根据需求重新派生一个实现类来扩展就可以了。当然前提是我们的抽象要合理,要对需求的变更有前瞻性和预见性才行。

  说到这里,再回想一下前面说的5项原则,恰恰是告诉我们用抽象构建框架,用实现扩展细节的注意事项而已:单一职责原则告诉我们实现类要职责单一;里氏替换原则告诉我们不要破坏继承体系;依赖倒置原则告诉我们要面向接口编程;接口隔离原则告诉我们在设计接口的时候要精简单一;迪米特法则告诉我们要降低耦合。而开闭原则是总纲,他告诉我们要对扩展开放,对修改关闭。

  最后说明一下如何去遵守这六个原则。对这六个原则的遵守并不是是和否的问题,而是多和少的问题,也就是说,我们一般不会说有没有遵守,而是说遵守程度的多少。任何事都是过犹不及,设计模式的六个设计原则也是一样,制定这六个原则的目的并不是要我们刻板的遵守他们,而需要根据实际情况灵活运用。对他们的遵守程度只要在一个合理的范围内,就算是良好的设计。我们用一幅图来说明一下。

  图中的每一条维度各代表一项原则,我们依据对这项原则的遵守程度在维度上画一个点,则如果对这项原则遵守的合理的话,这个点应该落在红色的同心圆内部;如果遵守的差,点将会在小圆内部;如果过度遵守,点将会落在大圆外部。一个良好的设计体现在图中,应该是六个顶点都在同心圆中的六边形。

  在上图中,设计1、设计2属于良好的设计,他们对六项原则的遵守程度都在合理的范围内;设计3、设计4设计虽然有些不足,但也基本可以接受;设计5则严重不足,对各项原则都没有很好的遵守;而设计6则遵守过渡了,设计5和设计6都是迫切需要重构的设计。

前端设计模式之迪米特法则(SRP)

译注:本文作者愤怒的韭菜

  定义:
  一个对象应该对其他对象保持最少的了解。

  问题由来:
  与类之间的关系越密切,耦合度越大,当一个类发生改变时,对另一个类的影响也越大。

  解决方案:
  尽量降低类与类之间的耦合。

  自从我们接触编程开始,就知道了软件编程的总的原则:低耦合,高内聚。无论是面向过程编程还是面向对象编程,只有使各个模块之间的耦合尽量的低,才能提高代码的复用率。低耦合的优点不言而喻,但是怎么样编程才能做到低耦合呢?那正是迪米特法则要去完成的。

  迪米特法则又叫最少知道原则,最早是在1987年由美国Northeastern University的Ian Holland提出。通俗的来讲,就是一个类对自己依赖的类知道的越少越好。也就是说,对于被依赖的类来说,无论逻辑多么复杂,都尽量地的将逻辑封装在类的内部,对外除了提供的public方法,不对外泄漏任何信息。迪米特法则还有一个更简单的定义:只与直接的朋友通信。首先来解释一下什么是直接的朋友:每个对象都会与其他对象有耦合关系,只要两个对象之间有耦合关系,我们就说这两个对象之间是朋友关系。耦合的方式很多,依赖、关联、组合、聚合等。其中,我们称出现成员变量、方法参数、方法返回值中的类为直接的朋友,而出现在局部变量中的类则不是直接的朋友。也就是说,陌生的类最好不要作为局部变量的形式出现在类的内部。

  举一个例子:有一个集团公司,下属单位有分公司和直属部门,现在要求打印出所有下属单位的员工ID。先来看一下违反迪米特法则的设计。

  定义1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
//总公司员工
class Employee{
private String id;
public void setId(String id){
this.id = id;
}
public String getId(){
return id;
}
}
//分公司员工
class SubEmployee{
private String id;
public void setId(String id){
this.id = id;
}
public String getId(){
return id;
}
}
class SubCompanyManager{
public List<SubEmployee> getAllEmployee(){
List<SubEmployee> list = new ArrayList<SubEmployee>();
for(int i=0; i<100; i++){
SubEmployee emp = new SubEmployee();
//为分公司人员按顺序分配一个ID
emp.setId("分公司"+i);
list.add(emp);
}
return list;
}
}
class CompanyManager{
public List<Employee> getAllEmployee(){
List<Employee> list = new ArrayList<Employee>();
for(int i=0; i<30; i++){
Employee emp = new Employee();
//为总公司人员按顺序分配一个ID
emp.setId("总公司"+i);
list.add(emp);
}
return list;
}
}
public void printAllEmployee(SubCompanyManager sub){
List<SubEmployee> list1 = sub.getAllEmployee();
for(SubEmployee e:list1){
System.out.println(e.getId());
}
List<Employee> list2 = this.getAllEmployee();
for(Employee e:list2){
System.out.println(e.getId());
}
}
public class Client{
public static void main(String[] args){
CompanyManager e = new CompanyManager();
e.printAllEmployee(new SubCompanyManager());
}
}

  现在这个设计的主要问题出在CompanyManager中,根据迪米特法则,只与直接的朋友发生通信,而SubEmployee类并不是CompanyManager类的直接朋友(以局部变量出现的耦合不属于直接朋友),从逻辑上讲总公司只与他的分公司耦合就行了,与分公司的员工并没有任何联系,这样设计显然是增加了不必要的耦合。按照迪米特法则,应该避免类中出现这样非直接朋友关系的耦合。修改后的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
class SubCompanyManager{
public List<SubEmployee> getAllEmployee(){
List<SubEmployee> list = new ArrayList<SubEmployee>();
for(int i=0; i<100; i++){
SubEmployee emp = new SubEmployee();
//为分公司人员按顺序分配一个ID
emp.setId("分公司"+i);
list.add(emp);
}
return list;
}
public void printEmployee(){
List<SubEmployee> list = this.getAllEmployee();
for(SubEmployee e:list){
System.out.println(e.getId());
}
}
}
class CompanyManager{
public List<Employee> getAllEmployee(){
List<Employee> list = new ArrayList<Employee>();
for(int i=0; i<30; i++){
Employee emp = new Employee();
//为总公司人员按顺序分配一个ID
emp.setId("总公司"+i);
list.add(emp);
}
return list;
}
public void printAllEmployee(SubCompanyManager sub){
sub.printEmployee();
List<Employee> list2 = this.getAllEmployee();
for(Employee e:list2){
System.out.println(e.getId());
}
}
}

  修改后,为分公司增加了打印人员ID的方法,总公司直接调用来打印,从而避免了与分公司的员工发生耦合。

  迪米特法则的初衷是降低类之间的耦合,由于每个类都减少了不必要的依赖,因此的确可以降低耦合关系。但是凡事都有度,虽然可以避免与非直接的类通信,但是要通信,必然会通过一个“中介”来发生联系,例如本例中,总公司就是通过分公司这个“中介”来与分公司的员工发生联系的。过分的使用迪米特原则,会产生大量这样的中介和传递类,导致系统复杂度变大。所以在采用迪米特法则时要反复权衡,既做到结构清晰,又要高内聚低耦合。

前端设计模式之接口隔离原则(ISP)

译注:本文作者愤怒的韭菜

  定义:
  客户端不应该依赖它不需要的接口;一个类对另一个类的依赖应该建立在最小的接口上。

  问题由来:
  类A通过接口I依赖类B,类C通过接口I依赖类D,如果接口I对于类A和类B来说不是最小接口,则类B和类D必须去实现他们不需要的方法。

  解决方案:
  将臃肿的接口I拆分为独立的几个接口,类A和类C分别与他们需要的接口建立依赖关系。也就是采用接口隔离原则。

  举例来说明接口隔离原则:


(图1 未遵循接口隔离原则的设计)

  这个图的意思是:类A依赖接口I中的方法1、方法2、方法3,类B是对类A依赖的实现。类C依赖接口I中的方法1、方法4、方法5,类D是对类C依赖的实现。对于类B和类D来说,虽然他们都存在着用不到的方法(也就是图中红色字体标记的方法),但由于实现了接口I,所以也必须要实现这些用不到的方法。对类图不熟悉的可以参照程序代码来理解,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
interface I{
public void method1();
public void method2();
public void method3();
public void method4();
public void method5();
}
class A{
public void depend1(I i){
i.method1();
}
public void depend1(I i){
i.method2();
}
public void depend1(I i){
i.method3();
}
}
class B implements I{
public void method1(){
System.out.println("类B实现接口I的方法1");
}
public void method2(){
System.out.println("类B实现接口I的方法2");
}
public void method3(){
System.out.println("类B实现接口I的方法3");
}
public void method4() {}
public void method5() {}
}
class C{
public void method1(){
i.method1();
}
public void method2(){
i.method4();
}
public void method3(){
i.method5();
}
}
class D implements I{
public void method1(){
System.out.println("类D实现接口I的方法1");
}
public void method2() {}
public void method3() {}
public void method4() {
System.out.println("类D实现接口I的方法4");
}
public void method5() {
System.out.println("类D实现接口I的方法5");
}
}
public class Client{
public static void main(String[] args){
A a = new A();
a.depend1(new B());
a.depend2(new B());
a.depend3(new B());
}
C c = new C();
c.depend1(new D());
c.depend2(new D());
c.depend3(new D());
}

  可以看到,如果接口过于臃肿,只要接口中出现的方法,不管对依赖于它的类有没有用处,实现类中都必须去实现这些方法,这显然不是好的设计。如果将这个设计修改为符合接口隔离原则,就必须对接口I进行拆分。在这里我们将原有的接口I拆分为三个接口,拆分后的设计如图2所示


(图2 遵循接口隔离原则的设计)

  照例贴出程序的代码,供不熟悉类图的朋友参考:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
interface I1 {
public void method1();
}
interface I2 {
public void method2();
public void method3();
}
interface I3 {
public void method4();
public void method5();
}
class A{
public void depend1(I1 i){
i.method1();
}
public void depend2(I2 i){
i.method2();
}
public void depend3(I2 i){
i.method3();
}
}
class B implements I1, I2{
public void method1() {
System.out.println("类B实现接口I1的方法1");
}
public void method2() {
System.out.println("类B实现接口I2的方法2");
}
public void method3() {
System.out.println("类B实现接口I2的方法3");
}
}
class C{
public void depend1(I1 i){
i.method1();
}
public void depend2(I3 i){
i.method4();
}
public void depend3(I3 i){
i.method5();
}
}
class D implements I1, I3{
public void method1() {
System.out.println("类D实现接口I1的方法1");
}
public void method4() {
System.out.println("类D实现接口I3的方法4");
}
public void method5() {
System.out.println("类D实现接口I3的方法5");
}
}

  接口隔离原则的含义是:建立单一接口,不要建立庞大臃肿的接口,尽量细化接口,接口中的方法尽量少。也就是说,我们要为各个类建立专用的接口,而不要试图去建立一个很庞大的接口供所有依赖它的类去调用。本文例子中,将一个庞大的接口变更为3个专用的接口所采用的就是接口隔离原则。在程序设计中,依赖几个专用的接口要比依赖一个综合的接口更灵活。接口是设计时对外部设定的“契约”,通过分散定义多个接口,可以预防外来变更的扩散,提高系统的灵活性和可维护性。

  说到这里,很多人会觉的接口隔离原则跟之前的单一职责原则很相似,其实不然。其一,单一职责原则原注重的是职责;而接口隔离原则注重对接口依赖的隔离。其二,单一职责原则主要是约束类,其次才是接口和方法,它针对的是程序中的实现和细节;而接口隔离原则主要约束接口,主要针对抽象,针对程序整体框架的构建。

  采用接口隔离原则对接口进行约束时,要注意以下几点:

  > 接口尽量小,但是要有限度。对接口进行细化可以提高程序设计灵活性是不挣的事实,但是如果过小,则会造成接口数量过多,使设计复杂化。所以一定要适度。
  > 为依赖接口的类定制服务,只暴露给调用的类它需要的方法,它不需要的方法则隐藏起来。只有专注地为一个模块提供定制服务,才能建立最小的依赖关系。
  > 提高内聚,减少对外交互。使接口用最少的方法去完成最多的事情。

  运用接口隔离原则,一定要适度,接口设计的过大或过小都不好。设计接口的时候,只有多花些时间去思考和筹划,才能准确地实践这一原则。

前端设计模式之依赖倒置原则(DIP)

译注:本文作者愤怒的韭菜

  定义:
  高层模块不应该依赖低层模块,二者都应该依赖其抽象;抽象不应该依赖细节;细节应该依赖抽象。

  问题由来:
  类A直接依赖类B,假如要将类A改为依赖类C,则必须通过修改类A的代码来达成。这种场景下,类A一般是高层模块,负责复杂的业务逻辑;类B和类C是低层模块,负责基本的原子操作;假如修改类A,会给程序带来不必要的风险。

  解决方案:
  将类A修改为依赖接口I,类B和类C各自实现接口I,类A通过接口I间接与类B或者类C发生联系,则会大大降低修改类A的几率。

  依赖倒置原则基于这样一个事实:相对于细节的多变性,抽象的东西要稳定的多。以抽象为基础搭建起来的架构比以细节为基础搭建起来的架构要稳定的多。在java中,抽象指的是接口或者抽象类,细节就是具体的实现类,使用接口或者抽象类的目的是制定好规范和契约,而不去涉及任何具体的操作,把展现细节的任务交给他们的实现类去完成。

  依赖倒置原则的核心思想是面向接口编程,我们依旧用一个例子来说明面向接口编程比相对于面向实现编程好在什么地方。场景是这样的,母亲给孩子讲故事,只要给她一本书,她就可以照着书给孩子讲故事了。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Book {
public String getContent(){
return '很久很久以前有一个阿拉伯的故事...';
}
}
class Mother{
public void narrate(Book book){
System.out.println("妈妈开始讲故事");
System.out.println(book.getContent());
}
}
public class Client{
public static void main(String[] args){
Mother month = new Month();
moth.narrate(new Book());
}
}

  运行结果:

1
2
妈妈开始讲故事
很久很久以前有一个阿拉伯的故事...

  运行良好,假如有一天,需求变成这样:不是给书而是给一份报纸,让这位母亲讲一下报纸上的故事,报纸的代码如下:

1
2
3
4
5
class Newspaper{
public String getContent(){
return '林书豪38+7领导尼克斯击败湖人……';
}
}

  这位母亲却办不到,因为她居然不会读报纸上的故事,这太荒唐了,只是将书换成报纸,居然必须要修改Mother才能读。假如以后需求换成杂志呢?换成网页呢?还要不断地修改Mother,这显然不是好的设计。原因就是Mother与Book之间的耦合性太高了,必须降低他们之间的耦合度才行。

  我们引入一个抽象的接口IReader。读物,只要是带字的都属于读物:

1
2
3
interface IReader{
public String getContent();
}

  Mother类与接口IReader发生依赖关系,而Book和Newspaper都属于读物的范畴,他们各自都去实现IReader接口,这样就符合依赖倒置原则了,代码修改为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Newspaper implements IReader{
public String getContent(){
return '林书豪38+7领导尼克斯击败湖人……';
}
}
class Book implements IReader{
public String getContent(){
return '很久很久以前有一个阿拉伯的故事...';
}
}
class Month{
public void narrate(IReader reader){
System.out.println("妈妈开始讲故事");
System.out.println(reader.getContent());
}
}
public class Client{
public static void main(String[] args){
Month month = new Month();
mother.narrate(new Book());
month.getContent(new Newspaper());
}
}

  运行结果:

1
2
3
4
妈妈开始讲故事
很久很久以前有一个阿拉伯的故事……
妈妈开始讲故事
林书豪17+9助尼克斯击败老鹰……

  这样修改后,无论以后怎样扩展Client类,都不需要再修改Mother类了。这只是一个简单的例子,实际情况中,代表高层模块的Mother类将负责完成主要的业务逻辑,一旦需要对它进行修改,引入错误的风险极大。所以遵循依赖倒置原则可以降低类之间的耦合性,提高系统的稳定性,降低修改程序造成的风险。

  采用依赖倒置原则给多人并行开发带来了极大的便利,比如上例中,原本Mother类与Book类直接耦合时,Mother类必须等Book类编码完成后才可以进行编码,因为Mother类依赖于Book类。修改后的程序则可以同时开工,互不影响,因为Mother与Book类一点关系也没有。参与协作开发的人越多、项目越庞大,采用依赖导致原则的意义就越重大。现在很流行的TDD开发模式就是依赖倒置原则最成功的应用

  传递依赖关系有三种方式,以上的例子中使用的方法是接口传递,另外还有两种传递方式:构造方法传递和setter方法传递,相信用过Spring框架的,对依赖的传递方式一定不会陌生。

  在实际编程中,我们一般需要做到如下3点:

  > 低层模块尽量都要有抽象类或接口,或者两者都有。
  > 变量的声明类型尽量是抽象类或接口。
  > 使用继承时遵循里氏替换原则。
  依赖倒置原则的核心就是要我们面向接口编程,理解了面向接口编程,也就理解了依赖倒置。

前端设计模式之里氏替换原则(LSP)

译注:本文作者愤怒的韭菜

  肯定有不少人跟我刚看到这项原则的时候一样,对这个原则的名字充满疑惑。其实原因就是这项原则最早是在1988年,由麻省理工学院的一位姓里的女士(Barbara Liskov)提出来的。

  定义1:
  如果对每一个类型为 T1的对象 o1,都有类型为 T2 的对象o2,使得以 T1定义的所有程序 P 在所有的对象 o1 都代换成 o2 时,程序 P 的行为没有发生变化,那么类型 T2 是类型 T1 的子类型。

  定义2:
  所有引用基类的地方必须能透明地使用其子类的对象。

  问题由来:
  有一功能P1,由类A完成。现需要将功能P1进行扩展,扩展后的功能为P,其中P由原有功能P1与新功能P2组成。新功能P由类A的子类B来完成,则子类B在完成新功能P2的同时,有可能会导致原有功能P1发生故障。

  解决方案:
  当使用继承时,遵循里氏替换原则。类B继承类A时,除添加新的方法完成新增功能P2外,尽量不要重写父类A的方法,也尽量不要重载父类A的方法。

  继承包含这样一层含义:父类中凡是已经实现好的方法(相对于抽象方法而言),实际上是在设定一系列的规范和契约,虽然它不强制要求所有的子类必须遵从这些契约,但是如果子类对这些非抽象方法任意修改,就会对整个继承体系造成破坏。而里氏替换原则就是表达了这一层含义。

  继承作为面向对象三大特性之一,在给程序设计带来巨大便利的同时,也带来了弊端。比如使用继承会给程序带来侵入性,程序的可移植性降低,增加了对象间的耦合性,如果一个类被其他的类所继承,则当这个类需要修改时,必须考虑到所有的子类,并且父类修改后,所有涉及到子类的功能都有可能会产生故障。

  举例说明继承的风险,我们需要完成一个两数相减的功能,由类A来负责。

1
2
3
4
5
6
7
8
9
10
11
12
13
class A{
public int func1(int a,int b){
return a-b;
}
}
public class Client{
public static void main(String[] args){
A a = new A();
System.out.println("100-50="+a.func1(100, 50));
System.out.println("100-80="+a.func1(100, 80));
}
}

  运行结果:

1
2
100-50=50
100-80=20

  后来,我们需要增加一个新的功能:完成两数相加,然后再与100求和,由类B来负责。即类B需要完成两个功能:

   > 两数相减。
   > 两数相加,然后再加100。

  由于类A已经实现了第一个功能,所以类B继承类A后,只需要再完成第二个功能就可以了,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class B extends A{
public int func1(int a, int b){
return a+b;
}
public int func2(int a, int b){
return func1(a,b)+100;
}
}
public class Client{
public static void main(String[] args){
B b = new B();
System.out.println("100-50="+b.func1(100, 50));
System.out.println("100-80="+b.func1(100, 80));
System.out.println("100+20+100="+b.func2(100, 20));
}
}

  类B完成后,运行结果:

1
2
3
100-50=150
100-80=180
100+20+100=220

  我们发现原本运行正常的相减功能发生了错误。原因就是类B在给方法起名时无意中重写了父类的方法,造成所有运行相减功能的代码全部调用了类B重写后的方法,造成原本运行正常的功能出现了错误。在本例中,引用基类A完成的功能,换成子类B之后,发生了异常。在实际编程中,我们常常会通过重写父类的方法来完成新的功能,这样写起来虽然简单,但是整个继承体系的可复用性会比较差,特别是运用多态比较频繁时,程序运行出错的几率非常大。如果非要重写父类的方法,比较通用的做法是:原来的父类和子类都继承一个更通俗的基类,原有的继承关系去掉,采用依赖、聚合,组合等关系代替。

  > 里氏替换原则通俗的来讲就是:子类可以扩展父类的功能,但不能改变父类原有的功能。它包含以下4层含义
  > 子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法。
  > 子类中可以增加自己特有的方法。
  > 当子类的方法重载父类的方法时,方法的前置条件(即方法的形参)要比父类方法的输入参数更宽松。
  > 当子类的方法实现父类的抽象方法时,方法的后置条件(即方法的返回值)要比父类更严格。

  看上去很不可思议,因为我们会发现在自己编程中常常会违反里氏替换原则,程序照样跑的好好的。所以大家都会产生这样的疑问,假如我非要不遵循里氏替换原则会有什么后果?

  后果就是:你写的代码出问题的几率将会大大增加。

前端设计模式之单一职责原则(SRP)

译注:本文作者愤怒的韭菜

  定义:
  不要存在多于一个导致类变更的原因。通俗的说,即一个类只负责一项职责。

  问题由来:
  类T负责两个不同的职责:职责P1,职责P2。当由于职责P1需求发生改变而需要修改类T时,有可能会导致原本运行正常的职责P2功能发生故障。

  解决方案:
  遵循单一职责原则。分别建立两个类T1、T2,使T1完成职责P1功能,T2完成职责P2功能。这样,当修改类T1时,不会使职责P2发生故障风险;同理,当修改T2时,也不会使职责P1发生故障风险。

  说到单一职责原则,很多人都会不屑一顾。因为它太简单了。稍有经验的程序员即使从来没有读过设计模式、从来没有听说过单一职责原则,在设计软件时也会自觉的遵守这一重要原则,因为这是常识。在软件编程中,谁也不希望因为修改了一个功能导致其他的功能发生故障。而避免出现这一问题的方法便是遵循单一职责原则。虽然单一职责原则如此简单,并且被认为是常识,但是即便是经验丰富的程序员写出的程序,也会有违背这一原则的代码存在。为什么会出现这种现象呢?因为有职责扩散。所谓职责扩散,就是因为某种原因,职责P被分化为粒度更细的职责P1和P2。

  比如:类T只负责一个职责P,这样设计是符合单一职责原则的。后来由于某种原因,也许是需求变更了,也许是程序的设计者境界提高了,需要将职责P细分为粒度更细的职责P1,P2,这时如果要使程序遵循单一职责原则,需要将类T也分解为两个类T1和T2,分别负责P1、P2两个职责。但是在程序已经写好的情况下,这样做简直太费时间了。所以,简单的修改类T,用它来负责两个职责是一个比较不错的选择,虽然这样做有悖于单一职责原则。(这样做的风险在于职责扩散的不确定性,因为我们不会想到这个职责P,在未来可能会扩散为P1,P2,P3,P4……Pn。所以记住,在职责扩散到我们无法控制的程度之前,立刻对代码进行重构。)

  举例说明,用一个类描述动物呼吸这个场景:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Animal{
public void breathe(String animal){
System.out.println(animal+"呼吸空气");
}}
public class Client{
public static void main(String[] args){
Animal animal = new Animal();
animal.breathe("牛");
animal.breathe("羊");
animal.breathe("猪");
}
}

  运行结果:

1
2
3
牛呼吸空气
羊呼吸空气
猪呼吸空气

  程序上线后,发现问题了,并不是所有的动物都呼吸空气的,比如鱼就是呼吸水的。修改时如果遵循单一职责原则,需要将Animal类细分为陆生动物类Terrestrial,水生动物Aquatic,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Terrestrial{
public void breathe(String animal){
System.out.println(animal+"呼吸空气");
}
}
class Aquatic{
public void breathe(String animal){
System.out.println(animal+"呼吸水");
}
}
public class Client{
public static void main(String[] args){
Terrestrial terrestrial = new Terrestrial();
terrestrial.breathe("牛");
terrestrial.breathe("羊");
terrestrial.breathe("猪");
Aquatic aquatic = new Aquatic();
aquatic.breathe("鱼");
}
}

  运行结果:

1
2
3
4
牛呼吸空气
羊呼吸空气
猪呼吸空气
鱼呼吸水

  我们会发现如果这样修改花销是很大的,除了将原来的类分解之外,还需要修改客户端。而直接修改类Animal来达成目的虽然违背了单一职责原则,但花销却小的多,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Animal{
public void breathe(String animal){
if("鱼".equals(animal)){
System.out.println(animal+"呼吸水");
}else{
System.out.println(animal+"呼吸空气");
}
}
}
public class Client{
public static void main(String[] args){
Animal animal = new Animal();
animal.breathe("牛");
animal.breathe("羊");
animal.breathe("猪");
animal.breathe("鱼");
}
}

  可以看到,这种修改方式要简单的多。但是却存在着隐患:有一天需要将鱼分为呼吸淡水的鱼和呼吸海水的鱼,则又需要修改Animal类的breathe方法,而对原有代码的修改会对调用“猪”“牛”“羊”等相关功能带来风险,也许某一天你会发现程序运行的结果变为“牛呼吸水”了。这种修改方式直接在代码级别上违背了单一职责原则,虽然修改起来最简单,但隐患却是最大的。还有一种修改方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Animal{
public void breathe(String animal){
System.out.println(animal+"呼吸空气");
}
public void breathe2(String animal){
System.out.println(animal+"呼吸水");
}
}
public class Client{
public static void main(String[] args){
Animal animal = new Animal();
animal.breathe("牛");
animal.breathe("羊");
animal.breathe("猪");
animal.breathe2("鱼");
}
}

  可以看到,这种修改方式没有改动原来的方法,而是在类中新加了一个方法,这样虽然也违背了单一职责原则,但在方法级别上却是符合单一职责原则的,因为它并没有动原来方法的代码。这三种方式各有优缺点,那么在实际编程中,采用哪一中呢?其实这真的比较难说,需要根据实际情况来确定。我的原则是:只有逻辑足够简单,才可以在代码级别上违反单一职责原则;只有类中方法数量足够少,才可以在方法级别上违反单一职责原则;

  例如本文所举的这个例子,它太简单了,它只有一个方法,所以,无论是在代码级别上违反单一职责原则,还是在方法级别上违反,都不会造成太大的影响。实际应用中的类都要复杂的多,一旦发生职责扩散而需要修改类时,除非这个类本身非常简单,否则还是遵循单一职责原则的好。

  遵循单一职责原的优点有:

  > 可以降低类的复杂度,一个类只负责一项职责,其逻辑肯定要比负责多项职责简单的多;
  > 提高类的可读性,提高系统的可维护性;
  > 变更引起的风险降低,变更是必然的,如果单一职责原则遵守的好,当修改一个功能时,可以显著降低对其他功能的影响。

  需要说明的一点是单一职责原则不只是面向对象编程思想所特有的,只要是模块化的程序设计,都适用单一职责原则。

Vim入门基础

[TOC]

@(welcome)[前端菜鸟|掘金中]

1. 简介

  Vim(Vi[Improved])编辑器是功能强大的跨平台文本文件编辑工具,继承自Unix系统的Vi编辑器,支持Linux/Mac OS X/Windows系统,利用它可以建立、修改文本文件。进入Vim编辑程序,可以在终端输入下面的命令:

1
$vim [filename]

  其中filename是要编辑器的文件的路径名。如果文件不存在,它将为你建立一个新文件。Vim编辑程序有三种操作模式,分别称为 编辑模式、插入模式 和 命令模式,当运行Vim时,首先进入编辑模式。

2. 编辑模式

  Vim编辑方式的主要用途是在被编辑的文件中移动光标的位置。一旦光标移到到所要的位置,就可以进行剪切和粘贴正文块,删除正文和插入新的正文。当完成所有的编辑工作后,需要保存编辑器结果,退出编辑程序回到终端,可以发出ZZ命令,连续按两次大写的Z键。

2.1 跳转

  如果键盘上有上、下、左、右箭头的导航键,就由这些键来完成光标的移动。另外,可以用下面的键完成同样的 按字符移动 功能:

1
2
3
4
k 上移;
j 下移;
h 左移;
l 右移。

  上面这4个键将光标位置每次移动一行或一个 字符 。Vim还提供稍大范围移动光标的命令:

1
2
ctrl+f 在文件中前移一页(相当于 page down);
ctrl+b 在文件中后移一页(相当于 page up);

  更大范围的移动:

1
2
3
4
5
6
7
8
9
10
11
12
* 当光标停留在一个单词上,* 键会在文件内搜索该单词,并跳转到下一处;
# 当光标停留在一个单词上,# 在文件内搜索该单词,并跳转到上一处;
(/) 移动到 前/后 句 的开始;
{/} 跳转到 当前/下一个 段落 的开始。
g_ 到本行最后一个不是 blank 字符的位置。
fa 到下一个为 a 的字符处,你也可以fs到下一个为s的字符。
t, 到逗号前的第一个字符。逗号可以变成其它字符。
3fa 在当前行查找第三个出现的 a。
F/T 和 f 和 t 一样,只不过是相反方向;
gg 将光标定位到文件第一行起始位置;
G 将光标定位到文件最后一行起始位置;
NG或Ngg 将光标定位到第 N 行的起始位置。

  在屏幕中找到需要的 一页 时,可以用下面的命令快速移动光标:

1
2
3
H 将光标移到屏幕上的起始行(或最上行);
M 将光标移到屏幕中间;
L 将光标移到屏幕最后一行。

  同样需要注意字母的大小写。HL命令还可以加数字。如2H表示将光标移到屏幕的第2行,3L表示将光标移到屏幕的倒数第3行。

  当将光标移到所要的行是,行内移动 光标可以用下面的命令来实现:

1
2
3
4
5
6
w 右移光标到下一个字的开头;
e 右移光标到一个字的末尾;
b 左移光标到前一个字的开头;
0 数字0,左移光标到本行的开始;
$ 右移光标,到本行的末尾;
^ 移动光标,到本行的第一个非空字符。

2.2 搜索匹配

  和许多先进的编辑器一样,Vim 提供了强大的字符串搜索功能。要查找文件中指定字或短语出现的位置,可以用Vim直接进行搜索,而不必以手工方式进行。搜索方法是:键入字符/,后面跟以要搜索的字符串,然后按回车键。编辑程序执行正向搜索(即朝文件末尾方向),并在找到指定字符串后,将光标停到该字符串的开头;键入n命令可以继续执行搜索,找出这一字符串下次出现的位置。用字符?取代/,可以实现反向搜索(朝文件开头方向)。例如:

1
2
3
4
/str1 正向搜索字符串 str1;
n 继续搜索,找出 str1 字符串下次出现的位置;
N 继续搜索,找出 str1 字符串上一次出现的位置;
?str2 反向搜索字符串 str2 。

  无论搜索方向如何,当到达文件末尾或开头时,搜索工作会循环到文件的另一端并继续执行。
Vim中执行搜索匹配最强大的地方是结合 正则表达式 来搜索,后续将会介绍。

2.3 替换和删除

  Vim常规的删除命令是 dx(前者删除 ,后者删除字符 ),结合Vim的其他特性可以实现基础的删除功能。将光标定位于文件内指定位置后,可以用其他字符来替换光标所指向的字符,或从当前光标位置删除一个或多个字符或一行、多行。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
rc 用 c 替换光标所指向的当前字符;
nrc 用 c 替换光标所指向的前 n 个字符;
5rA 用 A 替换光标所指向的前 5 个字符;
x 删除光标所指向的当前字符;
nx 删除光标所指向的前 n 个字符;
3x 删除光标所指向的前 3 个字符;
dw 删除光标右侧的字;
ndw 删除光标右侧的 n 个字;
3dw 删除光标右侧的 3 个字;
db 删除光标左侧的字;
ndb 删除光标左侧的 n 个字;
5db 删除光标左侧的 5 个字;
dd 删除光标所在行,并去除空隙;
ndd 删除(剪切) n 行内容,并去除空隙;
3dd 删除(剪切) 3 行内容,并去除空隙;

  其他常用的删除命令有:

1
2
3
d$ 从当前光标起删除字符直到行的结束;
d0 从当前光标起删除字符直到行的开始;
J 删除本行的回车符(CR),并和下一行合并。

  Vim常规的替换命令有cs,结合Vim的其他特性可以实现基础的替换功能,不过替换命令执行以后,通常会由 编辑模式 进入 插入模式

1
2
3
4
5
6
7
8
9
10
11
12
13
s 用输入的正文替换光标所指向的字符;
S 删除当前行,并进入插入模式;
ns 用输入的正文替换光标右侧 n 个字符;
nS 删除当前行在内的 n 行,并进入插入模式;
cw 用输入的正文替换光标右侧的字;
cW 用输入的正文替换从光标到行尾的所有字符(同 c$ );
ncw 用输入的正文替换光标右侧的 n 个字;
cb 用输入的正文替换光标左侧的字;
ncb 用输入的正文替换光标左侧的 n 个字;
cd 用输入的正文替换光标的所在行;
ncd 用输入的正文替换光标下面的 n 行;
c$ 用输入的正文替换从光标开始到本行末尾的所有字符;
c0 用输入的正文替换从本行开头到光标的所有字符。

2.4 复制粘贴

  从正文中删除的内容(如字符、字或行)并没有真正丢失,而是被剪切并复制到了一个内存缓冲区中。用户可将其粘贴到正文中的指定位置。完成这一操作的命令是:

1
2
p 小写字母 p,将缓冲区的内容粘贴到光标的后面;
P 大写字母 P,将缓冲区的内容粘贴到光标的前面。

  如果缓冲区的内容是字符或字,直接粘贴在光标的前面或后面;如果缓冲区的内容为整行正文,执行上述粘贴命令将会粘贴在当前光标所在行的上一行或下一行。

  注意上述两个命令中字母的大小写。Vim 编辑器经常以一对大、小写字母(如pP)来提供一对相似的功能。通常,小写命令在光标的后面进行操作,大写命令在光标的前面进行操作。

  有时需要复制一段正文到新位置,同时保留原有位置的内容。这种情况下,首先应当把指定内容复制(而不是剪切)到内存缓冲区。完成这一操作的命令是:

1
2
3
4
5
yy 复制当前行到内存缓冲区;
nyy 复制 n 行内容到内存缓冲区;
5yy 复制 5 行内容到内存缓冲区;
“+y 复制 1 行到操作系统的粘贴板;
“+nyy 复制 n 行到操作系统的粘贴板。

2.5 撤销和重复

  在编辑文档的过程中,为消除某个错误的编辑命令造成的后果,可以用撤消命令。另外,如果用户希望在新的光标位置重复前面执行过的编辑命令,可用重复命令。

1
2
u 撤消前一条命令的结果;
. 重复最后一条修改正文的命令。

3. 插入模式

3.1 进入插入模式

  在编辑模式下正确定位光标之后,可用以下命令切换到插入模式:

1
2
3
4
5
6
i 在光标左侧插入正文
a 在光标右侧插入正文
o 在光标所在行的下一行增添新行
O 在光标所在行的上一行增添新行
I 在光标所在行的开头插入
A 在光标所在行的末尾插入

3.2 退出插入模式

  退出插入模式的方法是,按 ESC 键或组合键 Ctrl+[ ,退出插入模式之后,将会进入编辑模式 。

4. 命令模式

  在已经启动的Vim中打开一个文件需要用 :e命令:

1
:e path_to_file/filename

  保存当前编辑的文件需要用 :w命令(单词 write 的缩写):

1
:w

  将当前文件另存为 file_temp 则:

1
:w file_temp

  在编辑模式下可以用 ZZ 命令退出Vim编辑程序,该命令保存对正文所作的修改,覆盖原始文件。如果只需要退出编辑程序,而不打算保存编辑的内容,可用下面的命令:

1
2
: q 在未作修改的情况下退出;
: q! 放弃所有修改,退出编辑程序。

  保存并退出则可以讲两条命令结合起来使用(注意命令顺序,先保存,后退出):

4.2 行号与文件

  编辑中的每一行正文都有自己的行号,用下列命令可以移动光标到指定行(效果与 编辑模式 下的 nggnG 相同):

1
: n 将光标移到第 n 行

  命令模式下,可以规定命令操作的行号范围。数值用来指定绝对行号;字符“.”表示光标所在行的行号;字符符“$”表示正文最后一行的行号;简单的表达式,例如“.+5”表示当前行往下的第 5 行。例如:

1
2
3
4
5
6
:345 将光标移到第 345 行
:345w file 将第 345 行写入 file 文件
:3,5w file 将第 3 行至第 5 行写入 file 文件
:1,.w file 将第 1 行至当前行写入 file 文件
:.,$w file 将当前行至最后一行写入 file 文件
:.,.+5w file 从当前行开始将 6 行内容写入 file 文件

  在命令模式下,允许从文件中读取正文,或将正文写入文件。例如:

1
2
3
4
5
6
7
8
:w 将编辑的内容写入原始文件,用来保存编辑的中间结果
:wq 将编辑的内容写入原始文件并退出编辑程序(相当于 ZZ 命令)
:w file 将编辑的内容写入 file 文件,保持原有文件的内容不变
:a,bw file 将第 a 行至第 b 行的内容写入 file 文件
:r file 读取 file 文件的内容,插入当前光标所在行的后面
:e file 编辑新文件 file 代替原有内容
:f file 将当前文件重命名为 file
:f 打印当前文件名称和状态,如文件的行数、光标所在的行号等

4.3 字符串搜索

  在 编辑模式 讲过字符串的搜索,此处的 命令模式 也可以进行字符串搜索,给出一个字符串,可以通过搜索该字符串到达指定行。如果希望进行正向搜索,将待搜索的字符串置于两个/之间;如果希望反向搜索,则将字符串放在两个?之间。例如:

1
2
3
4
:/str/ 正向搜索,将光标移到下一个包含字符串 str 的行
:?str? 反向搜索,将光标移到上一个包含字符串 str 的行
:/str/w file 正向搜索,并将第一个包含字符串 str 的行写入 file 文件
:/str1/,/str2/w file 正向搜索,并将包含字符串 str1 的行至包含字符串 str2 的行写

4.4 字符串搜索

  当给Vim指定搜索字符串时,可以包含具有特殊含义的字符。包含这些特殊字符的搜索字符串称为正则表达式(Regular Expressions)。例如,要搜索一行正文,这行正文的开头包含 struct 字。下面的命令做不到这一点:

1
:/struct/

  因为它只找出在行中任意位置包含struct的第一行,并不一定在行的开始包含struct。解决问题的办法是在搜索字符串前面加上特殊字符^:

1
:/^struct/

  ^字符比较每行开头的字符串。所以上面的命令表示:找出以字符串struct开头的行。

  也可以用类似办法在搜索字符串后面加上表示行的末尾的特殊字符 $ 来找出位于行末尾的字:

1
:/^struct/

  下表给出大多数特殊字符和它们的含义:

1
2
3
4
5
6
7
8
9
10
^ 放在字符串前面,匹配行首的字;
$ 放在字符串后面,匹配行尾的字;
\< 匹配一个字的字头;
\> 匹配一个字的字尾;
. 匹配任何单个正文字符;
[str] 匹配 str 中的任何单个字符;
[^str] 匹配任何不在 str 中的单个字符;
[a-b] 匹配 a 到 b 之间的任一字符;
* 匹配前一个字符的 0 次或多次出现;
\ 转义后面的字符。

4.5 正文替换

  利用:s命令可以实现字符串的替换。具体的用法包括:

1
2
3
4
5
6
:%s/str1/str2/ 用字符串 str2 替换行中首次出现的字符串 str1
:s/str1/str2/g 用字符串 str2 替换行中所有出现的字符串 str1
:.,$ s/str1/str2/g 用字符串 str2 替换正文当前行到末尾所有出现的字符串 str1
:1,$ s/str1/str2/g 用字符串 str2 替换正文中所有出现的字符串 str1
:g/str1/s//str2/g 功能同上
:m,ns/str1/str2/g 将从m行到n行的str1替换成str2

  从上述替换命令可以看到:

1
2
3
`g` 放在命令末尾,表示对搜索字符串的每次出现进行替换,不止匹配每行中的第一次出现;不加 `g`,表示只对搜索字符串的首次出现进行替换;`g` 放在命令开头,表示对正文中所有包含搜索字符串的行进行替换操作;
`s` 表示后面跟着一串替换的命令;
`%` 表示替换范围是所有行,即全文。

  另外一个实用的命令,在Vim中统计当前文件中字符串 str1 出现的次数,可用替换命令的变形:

1
:%s/str1/&/gn

4.6 删除正文

  在命令模式下,同样可以删除正文中的内容。例如:

1
2
3
4
5
6
7
8
:d 删除光标所在行
:3d 删除 3 行
:.,$d 删除当前行至正文的末尾
:/str1/,/str2/d 删除从字符串 str1 到 str2 的所有行
:g/^\(.*\)$\n\1$/d 删除连续相同的行,保留最后一行
:g/\%(^\1$\n\)\@<=\(.*\)$/d 删除连续相同的行,保留最开始一行
:g/^\s*$\n\s*$/d 删除连续多个空行,只保留一行空行
:5,20s/^#//g 删除5到20行开头的 # 注释

  总之,Vim的初级删除命令是用 d ,高级删除命令可以用 正则替换

4.7 恢复文件

  Vim 在编辑某个文件时,会另外生成一个临时文件,这个文件的名称通常以.开头,并以.swp结尾。Vim 在正常退出时,该文件被删除,若意外退出,而没有保存文件的最新修改内容,则可以使用恢复命令:recover来恢复文件,也可以在启动Vim时用-r选项。

4.8 选项设置

  为控制不同的编辑功能,Vim 提供了很多内部选项。利用 :set 命令可以设置选项。基本语法为:

1
:set option 设置选项 option

  常见的功能选项包括:

1
2
3
4
5
6
autoindent 设置该选项,则正文自动缩进
ignorecase 设置该选项,则忽略规则表达式中大小写字母的区别
number 设置该选项,则显示正文行号
ruler 设置该选项,则在屏幕底部显示光标所在行、列的位置
tabstop 设置按 Tab 键跳过的空格数。例如 :set tabstop=n,n 默认值为 8
mk 将选项保存在当前目录的 .exrc 文件中

4.9 Shell切换

  当处于编辑的对话过程中时,可能需要执行一些Linux命令。如果需要保存当前的结果,退出编辑程序,再执行所需的Linux命令,然后再回头继续编辑过程,就显得十分累赘。如果能在编辑的环境中运行Linux命令就要省事得多。在Vim中,可以用下面的命令来做到这一点:

1
:!shell_command 执行完 shell_command 后回到Vim

  这称为Shell切换。它允许执行任何可以在标准的Shell提示符下执行的命令。当这条命令执行完毕,控制返回给编辑程序。又可以继续编辑对话过程。

4.10 分屏与标签页

分屏

  普通的Vim模式,打开一个Vim程序只能查看一个文件,如果想同时查看多个文件,就需要用到Vim分屏与标签页功能。

  Vim的分屏,主要有两种方式:上下分屏(水平分屏)和左右分屏(垂直分屏),在命令模式分别敲入以下命令即可

1
2
:split(可用缩写 :sp) 上下分屏;
:vsplit(可用缩写 :vsp) 左右分屏。

  另外,也可以在终端里启动vim时就开启分屏操作:

1
2
vim -On file1 file2... 打开 file1 和 file2 ,垂直分屏
vim -on file1 file2... 打开 file1 和 file2 ,水平分屏

  理论上,一个Vim窗口,可以分为多个Vim屏幕,切换屏幕需要用键盘快捷键,命令分别有:

1
2
3
4
Ctrl+w+h 切换到当前分屏的左边一屏;
Ctrl+w+l 切换到当前分屏的右边一屏;
Ctrl+w+j 切换到当前分屏的下方一屏;
Ctrl+w+k 切换到当前分屏的上方一屏。

  即键盘上的h,j,k,l 四个Vim专用方向键,配合Ctrl键和w键(window的缩写),就能跳转到目标分屏。另外,也可以直接按 Ctrl+w+w来跳转分屏,不过跳转方向则是在当前Vim窗口所有分屏中,按照逆时针方向跳转。
  下面是改变尺寸的一些操作,主要是高度,对于宽度你可以使用 [Ctrl+W <] 或是 [Ctrl+W >] ,但这可能需要最新的版本才支持。

1
2
3
Ctrl+W = 让所有的屏都有一样的高度;
Ctrl+W + 增加高度;
Ctrl+W - 减少高度。

标签页

  Vim的标签(Tab)页,类似浏览器的标签页,一个标签页打开一个Vim的窗口,一个Vim的窗口可以支持N个分屏。

  在Vim中新建一个标签的命令是:

1
:tabnew

  如果要在新建标签页的同时打开一个文件,则可以在命令后面直接附带文件路径:

1
:tabnew filename

  Vim中的每个标签页有一个唯一的数字序号,第一个标签页的序号是0,从左向右依次加一。关于标签页有一系列操作命令,简介如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
:tN[ext] 跳转到上一个匹配的标签
:tabN[ext] 跳到上一个标签页
:tabc[lose] 关闭当前标签页
:tabdo 为每个标签页执行命令
:tabe[dit] 在新标签页里编辑文件
:tabf[ind] 寻找 'path' 里的文件,在新标签页里编辑之
:tabfir[st] 转到第一个标签页
:tabl[ast] 转到最后一个标签页
:tabm[ove] N 把标签页移到序号为N位置
:tabnew [filename] 在新标签页里编辑文件
:tabn[ext] 转到下一个标签页
:tabo[nly] 关闭所有除了当前标签页以外的所有标签页
:tabp[revious] 转到前一个标签页
:tabr[ewind] 转到第一个标签页

4.11 与外部工具集成

  Vim可以与许多外部程序集成,功能十分强大,比如 diff , ctags , sort , xxd 等等,下面选取几个简单介绍一下。

diff

  Linux命令diff用来对比两个文件的内容,不过对比结果显示在终端里,可读性比较差。结合Vim,在终端里可以直接输入命令vimdiff,后面跟两个文件名作为参数:

1
vimdiff file1 file2

  即可在Vim里分屏显示两个文件内容的对比结果,对文件内容差异部分进行高亮标记,还可以同步滚动两个文件内容,更可以实时修改文件内容,方便程度和用户体验大大提高。

1
vimdiff a.txt b.txt

  如果直接给 -d选项是一样的

1
vim -d a.txt b.txt

  除了在终端里开启vimdiff 功能,也可以在打开Vim后,在Vim的命令模式输入相关命令来开启 vimdiff 功能:

1
:diffsplit abc.txt

  如果你现在已经开启了一个文件,想Vim帮你区分你的文件跟 abc.txt 有什么区别,可以在Vim中用 diffsplit 的方式打开第二个文件,这个时 候Vim会用 split(分上下两屏)的方式开启第二个文件,并且通过颜色,fold 来显示两个文件的区别
  这样Vim就会用颜色帮你区分开2个文件的区别。如果文件比较大(源码)重复的部分会帮你折叠起来。

1
:diffpatch filename

  通过 :diffpatch你的patch的文件名,就可以以当前文件加上你的patch来显示。vim会split一个新的屏,显示patch后的信息并且用颜色标明区别。
  如果不喜欢上下对比,喜欢左右(比较符合视觉)可以在前面加 vert ,例如:

1
2
:vert diffsplit abc.txt
:vert diffpatch abc.txt

  看完diff,用:only 回到原本编辑的文件,觉得diff的讨厌颜色还是在哪里,只要用:diffoff 关闭就好了。
  还有个常用的diff中的就是 :diffu,这个是 :diffupdate 的简写,更新的时候用。

sort

  Linux命令 sort 可以对文本内容进行按行中的字符比较、排序,但在终端里使用 sort 命令处理文件,并不能实时查看文件内容。具体用法请自查手册。

xxd

  vim+xxd 是Linux下最常用的二进制文本编辑工具,xxd其实是Vim外部的一个转换程序,随Vim一起发布,在Vim里调用它来编辑二进制文本非常方便。
  首先以二进制模式在终端里打开一个文件:

1
vim -b filename

  Vim 的 -b 选项是告诉 Vim 打开的是一个二进制文件,不指定的话,会在后面加上 0x0a ,即一个换行符。
  然后在Vim的命令模式下键入:

1
:%!xxd

  即可看到二进制模式显示出来的文本,看起来像这样:

1
2
3
0000000: 1f8b 0808 39d7 173b 0203 7474 002b 4e49 ....9..;..tt.+NI
0000010: 4b2c 8660 eb9c ecac c462 eb94 345e 2e30 K,......b..4^.0
0000020: 373b 2731 0b22 0ca6 c1a2 d669 1035 39d9 7;'1.".....i.59

  然后就可以在二进制模式下编辑该文件,编辑后保存,然后用下面命令从二进制模式转换到普通模式:

1
:%!xxd -r

  另外,也可以调整二进制的显示模式,默认是 2 个字节为一组,可以通过 g 参数调整每组字节数:

1
2
3
:%!xxd -g 1 表示每1个字节为1组
:%!xxd -g 2 表示每2个字节为1组(默认)
:%!xxd -g 4 表示每4个字节为1组

5. Vim配置

  最初安装的Vim功能、特性支持比较少,用起来比较费劲,想要稍微“好用”一点,需做一些初步的配置。Vim的配置主要分为Vim本身特性的配置和外部插件的配置两部分。
  Vim的配置是通常是存放在用户主目录的 .vimrc 的隐藏文件中的。就Vim本身特性来说,基础的配置有编程语言语法高亮、缩进设置、行号显示、搜索高亮、TAB键设置、字体设置、Vim主题设置等等,稍微高级一些的有编程语言缩进、自动补全设置等,具体配置项可以自行查资料,全面详细的配置项介绍可以参考:
  《Vim Options》

6. Vim插件

  Vim“编辑器之神”的称号并不是浪得虚名,然而,这个荣誉的背后,或许近半的功劳要归功于强大的插件支持特性,以及社区开发的各种各样功能强大的插件。
  平时开发人员常用插件主要是目录(文件)查看和管理、编程语言缩进与自动补全、编程语言Docs支持、函数跳转、项目管理等等,简单配置可以参考下面:
  《Vim插件简单介绍》:
  《手把手教你把Vim改装成一个IDE编程环境(图文)》
  《将Vim改造为强大的IDE》

  当然,这些插件都是拜Vim本身的插件支持特性所赐。Vim为了支持丰富的第三方插件,自身定义了一套简单的脚本开发语言,供程序员自行开发自己所需要的插件,插件开发介绍可以参考:

  (《Writing Vim Plugins》)[http://stevelosh.com/blog/2011/09/writing-vim-plugins/]

7. Vim完整文档

  Vim官方文档
  Vim中文用户手册7_3.pdf

linux环境变量初始化与对应文件的生效顺序

  在登录Linux系统并启动一个bash shell时,默认情况下bash会在若干个文件中查找环境变量的设置。这些文件可统称为系统环境变量。bash检查的环境变量文件的情况取决于系统运行shell的方式。

  系统运行shell的方式一般有三种:

  一、通过系统用户登录后默认运行的shell

  二、非登录交互式运行shell

  三、执行脚本运行非交互式shell

  当用户登录Linux系统时,shell会作为登录shell启动。此时的登录shell加载环境变量的顺序如下

  用户登录系统后首先会加载/etc/profile全局变量文件,这是Linux系统上默认的shell主环境变量文件。系统上每个用户登录都会加载这个文件。

  当加载完/etc/profile文件后,才会执行/etc/profile.d目录下的脚本文件。

  之后开始运行$HOME/.bash_profile(用户环境变量文件),在这个文件中,又会去找$HOME/.bashrc(用户环境变量文件),如果有则执行,如果没有则不执行。在$HOME/.bashrc文件中又会去找/etc/bashrc(全局环境变量文件),如果有则执行,如果没有则不执行。

  如果用户的shell不是登录时启动的,非登录shell只会加载$HOME/.bashrc(用户环境变量文件),并会去找/etc/bashrc(全局环境变量文件)。如果希望在登录shell下也可以读到设置的环境变量等内容,就需要将变量设定等写入$HOME/.bashrc或者/etc/bashrc,而不是$HOME/.bash_profile或/etc/profile。

Linux文件层级结构标准(FHS)

  设计FHS(Filesystem Hierarchy Standard)的目的主要是为了给Unix-like系统的管理员提供一个管理系统以及目录结构的一个参考。

  由于利用Linux源码开发产品和发行版的企业或组织众多,如果每个组织都按照自己的喜好来组织系统中的文件结构,网络上必然出现各种各样的文件结构。这些产品的用户就不得不为每种不同的文件系统结构花费时间来学习。有了FHS之后,几乎所有的Unix-like系统的发行商或软件开发者都遵从该建议规范每个特定目录下分别只放哪些文件,方便了文件的组织和查找。

  FHS也根据实际使用的需求持续更新,完整的FHS可参考(http://www.pathname.com/fhs/)

/, /usr, /var


  FHS根据文件的可共享、不可共享、静态和动态等特点,将目录树架构分为/、/usr、/var三部分。

/ 根目录是整个系统最重要的一个目录,因为在Linux系统中所有的目录都必须挂载在根目录下,也即所有的目录都是由根目录衍生出来。系统开机是所需要的开机软件、内核文件、函数库等都必须在根目录下。因此FHS也建议根目录不要安装在非常大、数据存取非常活跃的分区上,如此一来就能尽可能地降低系统不必要的错误。建议:根目录越小越好,且应用程序安装目录不要和根目录放置于同一分区。根目录下一般建议由以下耳机子目录:

  • /bin 供所有用户使用的基本命令程序文件
  • /sbin 系统管理员使用的工具程序
  • /boot 引导加载器(bootloader)必须用到的静态文件:kernel、initramfs(initrd)、grub等
  • /dev 存储特殊文件或设备文件: 字符设备(线性设备)、块设备(随机设备)
  • /etc 系统程序的配置文件(只能为静态的,不能是二进制)
  • /lib、/lib64 为系统启动或根文件系统上的应用程序(/bin, /sbin)提供共享库(libc.so. ld),以及为内核提供内核模块(modules)
  • /media 便携式设备的挂载点,cdrom、floopy等
  • /mnt 其他文件系统的临时挂载点
  • /opt 第三方程序的安装位置,可选路径;
  • /srv 当前主机为服务提供的数据
  • /tmp 为那些会产生临时文件的程序提供的用于存储临时文件的目录
  • /usr user hierarchy 全局共享只读数据路径
  • /var var hierarchy存储常发生变化的文件
  • /proc 内核级进程存储其相关信息,多为内核参数,例如net.ipv4.ipforward虚拟为net/ipv4/ipforward,存储于/proc/sys
  • /sys sysfs虚拟文件系统提供了一种比proc更为理想的访问内核数据的途径;为管理内核提供了一种统一模型的接口

/usr 根据FHS建议软件安装后将他们的数据合理地分别放置到这个目录下,而不要自行新建该软件自己的独立目录。/usr放置的数据属于可分享的且不可变动的,/usr可分享给局域网内的其他主机来使用。

  • /usr/bin
  • /usr/sbin
  • /usr/lib
  • /usr/lib64
  • /usr/include C程序头文件
  • /usr/share 命令手册页和自带文档等
  • /usr/local 另一个层级结构,让系统管理员安装本地应用程序,通常用于安装第三方软件
  • /usr/src 程序的源码

/var 主要存储常变化的文件,包括缓存(cache)、登录文件(logfile)以及某些软件运行所产生的文件,包括程序文件(lock file, run file),或者例如MySQL数据库的文件等。

  • /var/cache 应用程序产生的缓存文件
  • /var/lib 程序执行过程中,需要用到的数据文件放置的目录。此目录下各自的软件应该要有各自的目录。
  • /var/lib 某些设备或者文件资源一次只能被一个应用程序所使用,如果同时有两个程序使用该设备时,就可能产生一些错误,因此就得要将该设备或文件上锁(lock),以确保该文件或设备只给一个程序使用。
  • /var/log 登录文件 /var/log/messages, /var/log/wtmp
  • /var/mail 个人电子邮箱目录,通常与/var/spool/mail/目录互为链接
  • /var/run 与运行中的进程相关的数据,PID等
  • /var/spool 存放等待其他程序使用的数据
  • /var/tmp 下次启动前会被删除的临时数据

前端网老姚浅谈:怎么学JavaScript?

  鉴于时不时,有同学私信问我(老姚,下同)怎么学前端的问题。这里统一回复一下,如下次再遇到问我此问题同学,就直接把本文链接地址发给你了。

  首先说句题外话。关于有人管我叫大神的事情。个人感觉这跟你买东西时,人家管你叫帅哥一样,你答应与否都无妨。

  正题开始,“前端怎么学”应该因人而异,别人的方法未必适合自己。就说说我的学习方法吧:我把大部分时间放在学习js上了。因为这个js的学习曲线,先平后陡。项目实践和练习啥的,我不说了,主要说下工作之外的时间利用问题。我是怎么学的呢,看书,分析源码。个人这几天统计了一下,前端书籍目前看了50多本吧,大部分都是js的。市面上的书基本,差不多都看过。

第一个问题是,看书有啥好处?

  好处应该是不言而明的,书看多了,基础会逐渐夯实起来。看多了,自己的判断力,自然就上来了。看别人的文章,就能很快判断出,对方每块儿讲得对不对,哪块儿又是自己不清楚的,模棱两可的。当然也为看源码,分析源码提供了基础。

  10本书读2遍的好处,应该大于一本书读20遍。10本书的交集,那就是基础知识的核心,而并集那就是所有的知识。好书当然要多读,反复读。但是只读一本是不行的。因为每本书的侧重点都不一样。从不同的侧面,去理解一个知识点,是很有意义的。

  所以特别佩服印度人,他跟你讲英文,你一个词语没听懂,他会蹦出n个同一意思的单词,你听懂一个,就ok了。看书也是这样的,某一块讲得不透彻,不用担心,其他书籍可以帮助你来了解。

第二个问题是,书籍推荐。

  个人觉得不错的,没事可以翻翻的。书籍如下:

   >《javascript面向对象编程指南》,风格轻松易懂,比较适合初学者,原型那块儿讲得透彻,12种继承方式呢。

   >《js权威指南》、《js高级程序设计》,这两本书经典是经典,但是太厚,适合把其中任意一章都当成一本书来读。洋洋洒洒,很难一口气看完。比较适合当做参考书。

   >《你不知道的javascript》狙击js核心细节,闭包、原型、this讲得都还清楚。目前《中册》也出了,还在看。

   >《js设计模式与开发实践》js设计模式也是要学的,此书把js的设计模式讲得非常清晰,一点不晦涩,看起来没多少难度。

   >《正则指引》,分析源码时,如果正则表达式不懂,没法进行下去的。此书相对来说讲得比较清晰。

   >《基于MVC的JavaScript Web富应用开发》,看完后,基本能写出自己的mvc框架了。是本好书。

   >《javascript函数式编程》,js是一门函数式语言,此书是函数式编程一个入门,函数是一等公民那是非常重要的。

   >《js忍者秘籍》,jq作者写的,没有传说中的那么难读,话说就算你看完并理解所有知识点,也不会达到世界高手级别的。因为你还没有做到随心所欲。

   >《javascript框架设计》,如果初看此书,会觉得此书有罗列代码之嫌。在我看来,此书讲究的是框架的全局观。以上书籍是我认为是成就高手之路上必须看的,也需要反复看。

   css相关的书籍,说实话我看得比较少,总共有六七本吧。有两本必须推荐一下:

   >《css权威指南》,css基础知识点那是讲得非常清楚的。什么层叠优先级、line-height啥的。不是随便一本书都敢叫“权威指南”的。

   >《css揭秘》,此书我也是不断的看,此书才不屑于全面讲css3各属性呢。css规范文档能讲的,它只会讲你最不在意的。此书解决的47问题,解决思路和解决方案同等重要,很有启发性。以上各书你都可以不买,至少买本此书吧。

第三个问题,怎么看。

  想必很多同学,都想看书,但是很难看下去。文字部分相对来说还能看看,一遇到代码,头皮就发麻了。此问题一开始时我也遇到的。

  说一个学习理论。比如说学英语,有个开水理论。词汇量必须达到6000才行,如果没达到,英文水平不会上去的,这跟烧开水一样,没事烧烧,放着凉凉,从来没烧到100度,那么此水是永远不能喝的。一旦煮沸过,就可以随时喝了。

  20本书你看不下去,说明什么呢?任何一本书,你都没看完过。熟悉的,永远只是前三章。别笑,我原先也是这样的。

  那么现在的问题是,怎么把一本书看完呢?很简单,敲。《基于MVC的JavaScript Web富应用开发》这本书我看时,就是这样,终于有一天,我下定决心要把此书从头到尾敲一遍。文字加代码都敲,然后就一章一章得看完了。代码敲一遍后,你会发现,没之前看起来的那么难。

  如果你属于一看书就犯困那种同学。强烈建议你把《javascript面向对象编程指南》此书从头到尾敲一遍。坚持看完一本书后,信心就上来了。先保证看完一本再说,看完3本后,基本应该能做到几天就能看一本了。万事开头难,加油吧。

第四个问题,看书的层次问题。

  书看完后,要自己总结,要与其他书籍对比看。有同学同时对比着看《权威指南》和《高设》来的。随便拿出个知识点,你都能闭着眼睛说得头头是道,说明水平够了。

  下一块就是源码的学习了。看框架源码之前,想说一件事情:dom的api不懂,没问题,你可以百度。

  但是正则一定要先研究研究,不然大多数人去尝试分析源码时,遇到的挫折都在于此。

怎么去阅读源码呢?敲,照着敲。

  有哪些代码值得去敲呢?优秀框架或者库的源码都值得你去敲。但是拿jq来敲,来入门,那不行的。原因:太他么长了。八九千行呢!!

  个人觉得underscore.js库是不错的第一个选择。原因都是工具方法,敲完以后自己的水平应该略有小成吧。其实有一些api的实现,你要把它当成getElementById一样,深深的印在脑海里。比如extend方法,必须张口就来。敲完underscore库后,可以考虑去看看《javascript函数式编程》这本书了。

  jq的源码不好敲。那么zepto的源码比较少1800多行,敲一天应该敲完了。敲几遍后,把所有不懂的地方,都百度清楚,然后就可以写自己的类jq的库了。然后就可以作为一项技能写进自己的简历里。比如“创建过自己的jquery库”。当然敲的过程,还能帮助自己对jq的api认识。

  然后是backbone.js,因为此框架是以类jq和underscore为基础的mvc框架。代码也没多少行。敲吧。spine.js与backbone类似。可以在敲其之前,先看看那本《基于MVC的JavaScript Web富应用开发》。希望你的简历可以添加这么一笔,“创建过自己的mvc框架”。

  其他的,我也敲过一些。包括jq.validate.js,包括一些插件。如果你愿意的话,bootstrap你可以去敲敲啊。源码挺多的,可以按插件逐个来敲。分析明白了,轮播、分页、下拉框等等的插件那还不是分分钟随手就写一个了。最起码看看人家api接口是怎么设计的也是极好的。话说个人在阅读其css代码中,也学到了不少东西。说到插件,有两个必须提提,一个是表格插件,一个是树。都敲完,简历里可以这么写上,“创建过自己的UI框架”。

  当然了,你也可以敲你喜欢的框架代码,重要的是明白其实现原理,最好理解其为啥那么设计,如果对设计模式比较熟悉的话,会经常发现原来是这么回事。

  照着敲只是分析源码的入门,用途也是为了学习,最后能用在自己的项目中,那是才是正道。就算没啥用,也是打发时间的好方式,比看电视剧强多了。我闲着无聊时,就背着敲underscore源码。最后说句,如果你简历上能如期写上那几句话后,必须是大神。加油吧。

后记

  写本文的最初目的,正如文章开头说的那样,方便自己回复大家的提问。本站的任何一篇分享学习经验的文章,基本都会引起共鸣,这确实是一个值得讨论的话题。这里再说说几个事情。

  >有人问我前端工作经验事情。

  没几年。三年多。

  >初学者或新手(beginner)怎么办?

  看书和分析源码是重要的提高方式,但不适合新手。新手需要的是能快速的入门和入行,能快速的上手工作。一种快捷的学习方式就是看视频。正如有的同学说得那样,知道有哪些东西,怎么用就可以了。

  看视频是有好处的,首先它是一种被动学习方式。我最开始的入门也是看视频来的,只需要看就行了。一遍没懂,再放一遍,我基本上是1.5倍数去看的。

  而读书是一种主动方式,需要自己一页一页翻。需要自己主动的去理解。而很多东西,也许只是视频老师一句话,就能突出的重点,需要我们自己去解读。还有另一件事情是,比如发现自己某个知识点不太清楚,可以单独去百度。比如this,文章很多的。这种学习方式也是快速掌握知识点的好办法。

  书籍需要技术评审,那么看文章一定要看看评论。不过视频就不好说了,视频一般都不会讲得太深入,偶尔也有讲错的。当年我也曾被一些视频误导过,建议找不错的视频看看。各大网站培训机构的免费视频挺多的。

  >看书和分析源码的时机。

  但已经工作一年半载时,正是提高的好时候,此时可以去看书了。全面系统的梳理知识点,扫清自己的盲区。如果只是靠项目经验是不够的,通过项目来学习,那肯定是必须的,工作本身就是一个学习的过程。

  但是工作三年不看书的话,学又能学到多少呢?更何况每个项目都很类似,一直处在舒适区,那真就是5年经验重复第一年的了。所以我不认同这句话:面试时强调自己的学习能力是工作能力不强的表现。3年经验的水平,完全有可能超过5年的。

  >没有时间去学习?

  如果你还没毕业,就已经天天在本站混了,其实你领先了一大步。都是混过大学的,天天充斥着lol和电视剧的陪伴,我只想说进入社会是要还的。最可怕的是什么呢?该还、还不还(这几个字别念错了)。时间是有的,就看你愿意付出不。下班后学习,周末学习,节假日别人玩的时候,在家敲代码,这样才能领先别人。

  >兴趣问题?

  兴趣和擅长是一个良性迭代循环。你擅长某件事情,就会越喜欢它,越喜欢,就越愿意花时间,进而越擅长。此道理都懂,只是缺乏一个trigger。

  如果你喜欢玩游戏的话,其实你可能非常适合做前端。玩游戏就是一个反馈机制,前端工作的反馈,相对其他工作来说也是非常及时的。代码一改,网页一刷,就看到效果了。擅长、优越感、成就感通常都是连在一起的。每看完一本书,我都觉得很有成就感。每敲完一个库,也有成就感。

  以上纯属一家之言,每个人的学习习惯、方式、态度都不一样。先端正态度、找到自己的学习方法,进而养成坚持下去的习惯。最后说一句,你我共勉:只要你走在正确的道路上,不管、走得多慢,都是前进!

  本文完。