首页 > 代码库 > 高屋建瓴:梳理编程约定
高屋建瓴:梳理编程约定
相关文章连接:
编程之基础:数据类型(一)
编程之基础:数据类型(二)
动力之源:代码中的“泵”
完整目录与前言
高屋建瓴:梳理编程约定
- 2.1 代码中的Client与Server 21
- 2.2 方法与线程的关系 22
- 2.3 调用线程与当前线程 24
- 2.4 阻塞方法与非阻塞方法 24
- 2.5 UI线程与线程 25
- 2.6 原子操作 26
- 2.7 线程安全 27
- 2.8 调用(Call)与回调(CallBack) 30
- 2.9 托管资源与非托管资源 31
- 2.10 框架(Frameworks)与库(Library) 32
- 2.11 面向(或基于)对象与面向(或基于)组件 33
- 2.12 接口 34
- 2.13 协议 35
- 2.14 本章回顾 39
- 2.15 本章思考 39
在实际编程中,我们会遇见各种各样的概念,虽然有的并没有官方定义,但是我们可以自己给它取一个形象的名称。本章总结了13条会在本书中出现的概念。
2.1 代码中的Client与Server
我们一般说到Client和Server,就会联想到网络通讯,TCP、UDP或者Socket这些概念马上就浮现在头脑中。其实Client和Server不仅仅可以用来形容网络通讯双方,它还可以用来形容代码中两个有交互的代码块。
通讯结构中的Client与Server有信息交互,一般Server为Client提供服务。代码中的一个方法调用另外一个对象的方法,同样涉及到信息交互,也同样可以看作这个对象为其提供服务。见下图2-1:
图2-1 Client与Server关系图
图2-1中"对象"称作Server,Client与Server可以不在一个程序集中,也可以不在同一个AppDomain里,更可以不在一个进程中,甚至都不在一台主机上。下面代码演示了Client与Server的关系:
1 //Code 2-1 2 3 class A 4 { 5 //… 6 public int DoSomething(int a,int b) 7 { 8 //do something here 9 return a+b;10 }11 }12 class Program13 {14 static void Main()15 {16 A a = new A();17 int result = a.DoSomething(3,4); //invoke public method a.DoSomething18 }19 }
代码Code 2-1中a对象是Server,Program是Client,前者为后者提供服务。
注:Client和Server不一定指的是对象,A程序集调用B程序集中的类型,我们可以把A当作Client,把B当作Server。Client与Server也不是绝对的,在一定场合,Client也可以看作是Server。
2.2 方法与线程的关系
线程和方法没有一对一的关系,也就是说,一个线程可以调用多个方法,而一个方法又可以被多个线程调用。由于在代码中,我们看得见的只有方法,因此有时候我们很难分清楚某个方法到底会运行在哪个线程之中。
1 //Code 2-2 2 3 class Program 4 { 5 static void DoSomething() 6 { 7 //do something here 8 } 9 static void Main()10 {11 //…12 Thread th1 = new Thread(new ThreadStart(DoSomething));13 Thread th2 = new Thread(new ThreadStart(DoSomething));14 th1.Start();15 th2.Start();16 }17 }
代码Code 2-2中的DoSomething方法可以同时运行在两个线程当中。以上代码还是比较直观的情况,有时候,一点线程的影子都看不见,
1 //Code 2-3 2 3 class Form1:Form 4 { 5 //… 6 private void DoSomething() 7 { 8 //do something here 9 //maybe invoke UI controls10 }11 private btn1_Click(object sender,EventArgs e)12 {13 BackgroundWorker back = new BackgroundWorker();14 back.DoWork += back_DoWork;15 back.Start();16 DoSomething(); //NO.117 }18 private void back_DoWork(object sender,DoWorkEventArgs e)19 {20 DoSomething(); //NO.221 }22 }
上面代码Code 2-3中有两处调用了DoSomething方法,一个在btn1.Click的事件处理程序中,一个在back.DoWork的事件处理程序中,前者在UI线程中运行,而后者在非UI线程中运行,两者可以同时进行。
当我们不确定我们编写的方法到底会在哪些线程中运行时,我们最好需要特别注意一下,如果方法访问了公共资源,多个线程同时执行这个方法时可能会引起资源异常。另外,只要我们确定了两个方法只会运行在同一个线程中,那么这两个方法不可能同时执行,跟方法处在的位置无关,
1 //Code 2-4 2 3 class Form1:Form 4 { 5 //… 6 private void DoSomething() 7 { 8 //do something here 9 //maybe invoke UI controls10 }11 private void btn1_Click(object sender,EventArgs e)12 {13 DoSomething(); //NO.114 }15 private void btn2_Click(object sender,EventArgs e)16 {17 DoSomething(); //NO.218 }19 }
上面代码Code 2-4中btn1.Click和btn2.Click的事件处理程序中都调用了DoSomething方法,但是由于btn1.Click和btn2.Click的事件处理程序都在UI线程中运行,所以这两处的DoSomething方法不可能同时执行,只可能一前一后,此时我们不需要考虑方法中访问的公共资源是否线程安全。
注:正常情况下,上面的结论成立,但是如果你非要在DoSomething中写了一些特殊代码,比如Application.DoEvents(),那么情况就不一定了,很有可能在btn1_Click中的DoSomething方法中调用btn2_Click方法,从而造成DoSomething方法还未结束,另一个DoSomething方法又开始执行,这个涉及到Windows消息循环的知识,本书第八章有讲到。
2.3 调用线程与当前线程
前一节中说明了线程与方法的关系,一个线程很少只调用一个启动方法,多数情况下,启动方法中会调用其它方法,一个方法在哪个线程中运行,那么这个线程就是它的当前线程,
1 //Code 2-5 2 3 class A 4 { 5 public void DoSomething() 6 { 7 //do something here 8 Console.WriteLine("currentthread is " + Thread.CurrentThread.Name); 9 }10 }11 class Program12 {13 //the start method of main thread14 static void Main()15 {16 A a = new A();17 a.DoSomething(); //NO.118 Thread th1 = new Thread(new ThreadStart(th1_proc));19 th1.Start();20 }21 static void th1_proc()22 {23 A a = new A();24 a.DoSomething(); //NO.225 }26 }
上面代码Code 2-5中,在NO.1处,主线程就是调用线程,它调用了a.DoSomething方法,这时候a.DoSomething中会输出主线程的Name属性值。在NO.2处,th1才是调用线程,它调用了a.DoSomething方法,这时候a.DoSomething中会输出th1线程的Name属性值。
也就是说,哪个线程调用了方法,哪个线程就叫做这个方法的调用线程,方法在哪个线程中运行,哪个线程就是该方法的当前线程。
2.4 阻塞方法与非阻塞方法
首先,阻塞和非阻塞的概念是相对的,一个方法耗时很长才能返回,返回之前会一直阻塞调用线程,我们叫它阻塞方法;相反,一个方法耗时短,一调用马上就返回,我们叫它非阻塞方法。但是这个"很长"与"很短"根本就没有标准,
1 //Code 2-6 2 3 class Program 4 { 5 static void Func1() 6 { 7 for(int i=0;i<100;++i) 8 { 9 Thread.Sleep(10);10 }11 }12 13 static void Func2()14 {15 for(int i=0;i<100;++i)16 for(int j=0;j<100;++j)17 {18 Thread.Sleep(10);19 }20 }21 static void Main()22 {23 Func1(); //NO.124 Console.WriteLine("Func1 over");25 Func2(); //NO.226 Console.WriteLine("Func2 over");27 }28 }
上面代码Code 2-6中,Func1相对于Func2来讲,耗时短,我们把Func1叫做非阻塞方法,Func1不会阻塞它的调用线程,下面的Console.WriteLine很快就会执行;而相反,Func2耗时长,我们把Func2叫做阻塞方法,Func2会阻塞它的调用线程,下面的Console.WriteLine不能马上执行。
现实编程中,也没有严格标准,如果一个方法有可能耗时长,那么就把它当作阻塞方法。在编程中,需要注意阻塞方法和非阻塞方法的使用场合,有的线程中不应该调用阻塞方法,比如Winform中的UI线程。
有时候一个类会提供两个功能相同的方法,一种是阻塞方法,它会阻塞调用线程,一直等到任务执行完毕才返回,另一种是非阻塞方法,不管任务有没有执行完毕,马上就会返回,不会阻塞调用线程,至于任务何时执行完毕,它会以另一种方式通知调用线程。这两种调用方式也称为"同步调用"和"异步调用",FileStream.Read和FileStream.BeginRead就属于这一类。
注:同步调用和异步调用在后面的章节会讲到,异步编程模型(Asynchronous Programming Model)是.NET中一项重要技术。我们既可以把耗时10S的方法称为阻塞方法,也可以把耗时100MS的方法称为阻塞方法,理论上没有标准。
2.5 UI线程与线程
UI线程一般出现在Winform编程中,主要负责用户界面(User Interface)的消息处理。本质上,UI线程跟普通线程没有什么区别。
一个线程只有不停地循环去处理任务才不会马上终止,也就是说,线程必须想办法去维持它的运行,不然很快就会运行结束。UI线程中包含一个Windows消息循环,使用常见的While结构实现,该循环不停地获取用户输入,包括鼠标、键盘等输入信息,然后不停地处理这些信息,正因为有这样一个While循环存在,UI线程才不会一开始就马上结束。
图2-2 一个维持线程运行的循环结构
Winform中的UI线程默认由Program.Main方法中的Application.Run()进入,While循环结构存在于Application.Run()内部(或者由其调用)。
注:详细的Windows消息循环,请参见第八章。使用任何语言开发的Windows桌面应用程序都至少包含一个UI线程。
2.6 原子操作
所谓"原子",即不可再分的意思。代码中的原子操作指代码执行的最小单元,也就是说,原子操作不可以被中断,它只有三个状态:未执行、正在执行和执行完毕,绝对没有执行到一半暂停下来,等待一会,又继续执行的情况。原子操作又称为程序中不可以被线程调度打断的操作。
比如给一个整型变量赋值"int a = 1;",这个操作就是原子操作,不可能有给a赋值到一半,操作暂停的情况。相反有很多操作,属于非原子操作,比如对一些集合容器的操作,向某些集合容器中增加删除元素等操作都是非原子操作,这些操作可能被打断,出现操作一半暂停的情况。非原子操作由许许多多的原子操作组成。
图2-3 原子操作与非原子操作
图2-3中虚线框表示一个非原子操作,一个非原子操作由多个原子操作组成,虚线框中的操作可能在NO.1、NO.2、 NO.3任何一个地方暂停。
注:原子操作与非原子操作不是以代码行数来区分的,是以这个操作在底层怎么实施去区分的,比如"a++;"只有一行代码,但是它不是原子操作,它底层实现是由许多原子操作组合而成。
2.7 线程安全
我们操作一个对象(比如调用它的方法或者给属性赋值),如果该操作为非原子操作,也就是说,可能操作还没完成就暂停了,这个时候如果有另外一个线程开始运行同时也操作这个对象,访问了同样的方法(或属性),这个时候可能会出现一种问题:前一个操作还未结束,后一个操作就开始了,前后两个操作一起就会出现混乱。
当多个线程同时访问一个对象(资源)时,如果每次执行可能得到不一样的结果,甚至出现异常,我们认为这个对象(资源)是"非线程安全"的。造成一个对象非线程安全的因素有很多,上面提到的由于非原子操作执行到一半就中断是一种,还有一种情况是多CPU情况中,就算操作没有中断,由于多个CPU可以真正实现多线程同时运行,所以还是有可能出现"对同一对象同时操作出现混乱"的情况。
图2-4 两种可能引起非线程安全的情况
图2-4中左边两个线程运行在单CPU系统中,A线程中的非原子操作中断,对R的操作暂停,B线程开始操作R,前后两次操作相互干扰,可能出现异常。图中右边两个线程运行在双CPU中,无论操作是否中断,都可能出现两个操作相互干扰的情况。
为了解决多线程访问同一资源有可能引起的不稳定性,我们需要在操作方法中做一些改进,最常见的是:对可能引起不稳定的操作加锁。在代码中使用lock代码块、互斥对象等来实现。如果一个对象,在多个线程访问它时,不会出现结果不稳定或异常情况,我们称该对象为"线程安全"的,也称访问它的方法是"线程安全"的。
1 //Code 2-7 2 3 class A 4 { 5 //… 6 int _a1 = 0; 7 int _a2 = 0; 8 object _syncObj = new object(); 9 public Int Result10 {11 get12 {13 lock(_syncObj)14 {15 if(_a2!=0) //NO.116 {17 return _a1/_a2; //NO.218 }19 else20 {21 return 0;22 }23 }24 }25 }26 public void DoSomething(int a1, int a2)27 {28 lock(_syncObj)29 {30 _a1 = a1;31 _a2 = a2;32 }33 }34 //other public methods35 }
上面代码Code 2-7中,单CPU时,如果没有lock块,多线程访问A类对象,一个线程在访问A.Result属性时,在判断if(_a2!=0)为true后,可能在NO.1之后和NO.2之前处出现中断(线程挂起),此时另一线程通过DoSomething方法修改_a2的值为0,中断恢复后,程序报错。双CPU中,如果没有lock块,多线程访问A类对象,情况更糟,一个线程访问A.Result属性时,不管在NO.1之后和NO.2之前会不会中断,另一个线程都有可能通过DoSomething方法修改_a2的值为0,程序报错。
另外,在Winform编程中,我们常遇见的"不在创建控件的线程中访问该控件"的异常,原因就是对UI控件的操作几乎都不是线程安全的(部分是),一般UI控件只能由UI线程操作,其余的所有操作均需要投递到UI线程之中执行,否则就像前面讲的,程序出现异常或不稳定。
1 //Code 2-8 2 3 class Form1:Form 4 { 5 //… 6 private btn1_Click(object sender,EventArgs e) 7 { 8 DealControl(null); //NO.1 9 Thread th1 = new Thread(new ThreadStart(th1_proc));10 th1.Start();11 }12 private void th1_proc()13 {14 DealControl(null); //NO.215 }16 private void DealControl(object[] args)17 {18 //…19 if(this.InvokeRequired) //NO.320 {21 this.Invoke((Action)delegate() //NO.422 {23 DealControl(args);24 });25 }26 else27 {28 //access ui controls directly29 //…30 }31 }32 }
上面代码Code 2-8中,DealControl方法中需要操作UI控件,如果我们不知道DealControl到底会在哪个线程中运行,有可能在UI线程也有可能在非UI线程,那么我们可以使用Control.InvokeRequired属性去判断当前线程是否是创建控件的线程(UI线程),如果是,则该属性返回false,可以直接操作UI控件,否则,返回true,不能直接操作UI控件。代码中NO.1处直接在UI线程中调用DealControl,DealControl中可以直接操作UI控件,NO.2处在非UI线程中调用DealControl,那么此时,就需要将所有的操作通过Control.Invoke投递封送到UI线程之中执行。
注:Control包含有若干个线程安全的方法和属性,我们可以在非UI线程中使用它们。有Control.InvokeRequired属性、Control.Invoke、Control.BeginInvoke(Control.Invoke的异步版本,后续章节有讲到)、Control.EndInvoke以及Control.CreateGraphics方法。跨线程访问这些方法和属性不会引起异常。
2.8 调用(Call)与回调(CallBack)
调用(Call)和回调(CallBack)是编程中最常遇见的概念之一,几乎出现在代码中的每一处,只是许多人并没有在意。现在最流行的解释是:调用指我们调用系统的方法,回调指系统调用我们写的方法。类似下面图2-5描述的:
图2-5 调用与回调的区别
上图2-5是目前对"调用"和"回调"的解释。但是需要清楚一点,本章第一节中已经讲到过,客户端(图中Client)并不是绝对的,也就是说,Client也有可能成为图中的"系统"部分,别人再调用它,它再回调另一个client。
图2-6 程序中调用与回调的关系
图2-6中描述一个程序中调用与回调的关系,我们平常对"调用"和"回调"的定义只局限在图中虚线框中,它只是一个小范围的规定。严格意义上讲,不应该有调用和回调之分,因为所有代码最终均由操作系统调用(甚至更底层)。
.NET中的回调主要是通过委托(Delegate)来实现的,委托是一种代理,专门负责调用方法(委托的详细信息在本书第五章有讲到)。
2.9 托管资源与非托管资源
其实这里的"托管"跟第一章中讲到的托管环境、托管代码或者托管时代中的"托管"意思一样。在.NET中,对象使用的资源分两种:一种是托管资源,一种是非托管资源。托管资源由CLR管理,也就是说不需要开发人员去人工控制,相对开发人员来讲,托管资源的管理几乎可以忽略,.NET中托管资源主要指"对象在堆中的内存"等;非托管资源指对象使用到的一些托管环境以外(比如操作系统)的资源,CLR不会管理这些资源,需要开发人员人工去控制。.NET中对象使用到的非托管资源主要有I/O流、数据库连接、Socket连接、窗口句柄等各种直接与操作系统相关的资源。
图2-7 一个堆中对象使用的资源
图2-7中虚线框表示"可能有",即一个堆中对象可能使用到了非托管资源,但是它一定使用了托管资源。一个对象在使用完毕后(进入不可达状态,并不是死亡,第四章会讲到区别),我们应该确保它使用的(如果使用了)非托管资源能够及时释放,归还给操作系统,至于托管资源,我们大部分时间不需要去关心,因为CLR(具体应该是Garbage Collector)会帮我们处理。.NET中使用了非托管资源的类型有很多,比如FileStream、Socket、Font、Control(及其派生类)、SqlDataConnection等等,它们内部封装了非托管资源,没有使用非托管资源的类型也有很多,比如Console、EventArgs、ArrayList等等。
怎么完美地处理一个对象使用的非托管资源,是一门相当重要而且必学的技术,后面第四章有详细提到。
注:现在普遍有一种错误的观点就是,将FileStream、Socket这样的类型对象称为非托管资源,这个是错误的,只能说这些对象使用到了非托管资源。
2.10 框架(Frameworks)与库(Library)
框架和类库都是一系列可以被重用的代码集合。不同的是,框架算是不完整的应用程序,理论上,我们不用写任何代码,框架本身可以运行起来;而类库多半指能够提供一些具体功能的类集合,它包含的内容和功能一般比框架更简单。我们使用框架去开发一个应用程序,其实就是在框架的基础上写一些扩展代码,框架就像一个没有装修的毛坯房屋,我们需要给它各种装饰,在这个过程中,我们可以使用类库,因为类库可以为我们提供一些封装好了的功能。下图2-8为框架、程序(开发人员编写)以及类库三者之间的关系:
图2-8 框架程序类库之间的关系
图2-8中的调用关系其实是双向的,画出的箭头只显示了主要调用关系,即框架调用开发人员代码,后者再选择性调用一些类库。
从上图2-8中我们可以看出,整个应用程序的最终控制权并不在开发人员手中,而是在框架方,这种现象称为"控制转换"(Inversion Of Control,IOC),即程序的运行流程由框架控制,几乎所有框架都遵循这个规则。
1 //Code 2-9 2 3 class Program 4 { 5 //… 6 static int GetTotal(int first,int second) 7 { 8 return first + second; 9 }10 static void Main()11 {12 int first,second;13 Console.WriteLine("Input first:");14 first = int.Parse(Console.ReadLine()); //NO.115 Console.WriteLine("Input second:");16 second = int.Parse(Console.ReadLine()); //NO.217 int total = GetTotal(first,second); //NO.318 Console.WriteLine("the total is:" + total);19 Console.Read();20 }21 }
上面代码Code 2-9演示了从控制台程序(不使用框架开发)中获取用户输入的两个数据,然后输出两个数据之和,每个步骤的方法均由我们自己调用(NO.1. NO.2以及NO.3)。如果我们采用Winform程序(使用框架开发)实现,代码如下:
1 //Code 2-10 2 3 class Form1:Form 4 { 5 public Form1() 6 { 7 //… 8 this.btn1.Click+=(EventHandler)(delegate(object sender,EventArgs e) 9 {10 int first = int.Parse(txtFirst.Text); //NO.111 int second = int.Parse(txtSecond.Text); //NO.212 int total = GetTotal(first,second); //NO.313 MessageBox.Show("the total is:" + total);14 });15 }16 private int GetTotal(int first,int second)17 {18 return first + second;19 }20 21 }
上面代码Code 2-10演示了从窗体界面中的txtFirst和txtSecond两个文本框中获取数据,然后计算出两个数据之和,每个步骤的方法都是由系统(框架)调用(在btn1.Click事件处理程序中)。使用框架开发的程序,代码中大部分方法都属于"回调方法"。
注:"控制转换原则"又称为"Hollywood Principle",即Don‘t call us, we will call you.意思是指好莱坞制片公司会主动联系演员,而不需要演员自己去找电影制片公司。
2.11 面向(或基于)对象与面向(或基于)组件
这四个概念中最为熟悉的当然是"面向对象",其它三个离我们有点遥远,平时接触不多。
基于对象:如果一种编程语言有封装的概念,能够将数据和操作封装在一起,形成一个整体,同时它又不具备像继承、多态这些OO特性,那么就说这种语言是基于对象的,比如JavaScript。
面向对象:在基于对象的基础之上,还具备继承、多态特性的编程语言,我们称该编程语言是面向对象的,比如C#,Java。
基于组件:组件是共享二进制代码的基本单元,它是一个已经编译完成的模块,可以在多个系统中重用。在软件开发中,我们事先定义好固定接口,然后将各个功能分开独立开发,最后生成各自独立的模块。程序运行之后,分别加载这些独立的模块,各个模块负责完成自己的功能,我们称这种开发模式是基于组件的。基于组件开发模式中,除了二进制代码可以重用外,还有另外一个优点,如果我们需要更新某一功能,或修复某一功能中的bug,在不改变原有接口前提下,我们不用重新编译整个程序的源代码,而只需要重新编译某个组件源码即可。组件应该是语言独立的,一种语言开发出来的组件,理论上任何一种语言都可以使用它。
面向组件:基于组件开发中,我们只能重用已经编译完成的二进制代码,并不能从这个已经编译好的组件中读取其它信息,比如识别组件中的类型信息,派生出新的类型。面向组件指,在开发过程中,我们不仅能够重用组件中的代码,还能以该组件为基础,扩展出新的组件,比如我们可以识别.NET程序集中的类型信息,以此派生出新的类型。.NET开发便是一种面向组件的开发模式。
注:如果说面向对象是强调类型与类型之间的关系,那么面向组件就是强调组件与组件之间的关系。另外,我们需要知道,.NET中的组件(程序集)并不包含传统意义的二进制代码。
2.12 接口
我们在阅读一些书籍或者网上浏览一些文章时,经常会碰到"接口"的概念,比如"一个类应该尽可能少的对外提供公共接口"、"我们应该先取得淘宝的支付接口权限"、"绘制图形时,我们需要调用系统的DrawImage接口"等等。那么,接口到底是什么?
其实我们碰到的这些"接口"概念跟它字面意思一样:对外提供的、可以完成某项具体功能的通道。比如我们电脑上的USB口,通过它,我们能够与电脑传输数据,还比如电视机的音量按钮,通过它,我们可以调节电视机喇叭发出声音的大小。接口是外界与系统(或模块)内部通讯的通道。
注:"接口"的概念基于"封装"前提之上,如果没有"封装",那么就没有"外界"与"内部"之说。
在软件一般架构设计图中,接口用以下表示:
图2-9 接口示意图
如上图2-9所示,圆圈代表对外公开的通道,S的内部细节对外界C是不可见的。注意图中的S不一定代表一个类,它可以是一个系统(跟C所属不同的系统)、一个模块或者其它具有"封装"效果的单元个体。下图2-10显示某些场合存在的接口:
图2-10 各种场合下的接口
如上图2-10显示了各种场合中的接口,可以看到,接口的概念不仅局限在代码层面。下表2-1显示了各种接口的表现形式:
表2-1各种场合中接口的具体表现形式
序号 | 场合 | 接口的表现形式 | 谁是外界 | 说明 |
1 | 类 | 类的公开方法,如 People p = new People(); p.Walk(); | 类的使用者 | 类的使用者不知道People类内部具体实现,但是可以与之通讯 |
2 | 操作系统 | Win32 API,如 SetWindowText(hWnd,”text”); //设置某窗口标题 | GUI开发者 | GUI开发者不知道操作系统内部实现,但是可以与之通讯 |
3 | 微博开放平台 | https协议url,如加载最新微博 https://api.weibo.com/2/statuses/public_timeliti.json?parameter1=12¶meter2=22 | 微博第三方应用开发者 | 微博第三方应用开发者不知道微博服务器内部实现,但是可以与之通讯 |
4 | Google地图服务 | http协议url,如查询指定城市 地理坐标信息 http://maps.googleapis.com/maps/api/geocode/xml?address=london&sensor=false | 地图第三方应用开发者 | 地图第三方应用开发者不知道地图服务器内部实现,但是可以与之通讯 |
在.NET编程中,还存在另外一种意义的"接口",即我们使用interface关键字定义的接口类型,这种"接口"严格意义上讲跟我们刚才讨论的"接口"不能做相等比较。更准确来说,它代表编程过程中的一种"协议",是代码中调用方和被调用方必须遵守的契约,如果某一方不遵守,那么调用就不会成功。
注:有关"协议",请参见下一节。
2.13 协议
协议,即约定、契约。两个(或两个以上)个体合作时需要共同遵守的准则,哪一方不遵守该准则,大部分时候将会导致合作失败,这个是现实生活中我们理解的"协议"。在计算机(编程)世界中,"协议"带来的效果同样如此。
计算机网络通信中,OSI(Open System Interconnection,开放系统互联模型)将网络分为7层,每层均有多种协议,通信双方必须分别遵守各层中对应的协议,如下图2-11:
图2-11 网络七层协议
如上图2-11所示,数据发送方必须按照规定协议封装数据,然后才能发送给另一方;同理,数据接收方必须按照对应协议解析接收到的数据包,然后才能获得发送方发送的原始数据。在实际通信编程中,这些"封装/解析"的步骤均已被计算机底层模块完成,因此对用户来讲,这些过程都是透明的,它们一直都在,并且是双方通信的关键。
网络通信协议是一种数据结构,很多书籍中讲到了TCP/UDP协议结构,介绍了协议结构中每(几)个字节分别代表什么内容,数据发送方按照规定的格式填充该数据结构,数据接收方按照规定的格式去解析该数据结构,从而得到原始数据。不管TCP协议还是UDP协议,均属于传输层协议。对于某些高级语言(如C#、Java)开发者而言,接触这些协议的机会很少,更多时候,我们接触的是应用层协议,如HTTP协议、FTP协议等,除了这些主流、广为人知的协议外,我们自己在开发网络程序时,也可以自己定义自己的应用层协议,如在编写雷达航迹显示系统时,我们可以将接收到的原始雷达数据进行预处理,以某一种预先定义的数据结构(也就是协议)转发给其他人,其他人按照预先定义好的数据结构(协议)去解析接收到的数据包;还比如在一些即时通信程序中,可能存在"文本消息"、"图片"、"表情"或者"文件"等一些数据类型,那么我们完全可以定义一个自己的应用层协议,见下图2-12:
图2-12 自定义应用层协议
如上图2-12所示,第一个字节表示消息类型,是文本消息还是表情,可以通过该字节区分,第2~5个字节表示双方通信次数,第6~9个字节表示"数据区"长度,之后的N个字节表示发送的"原始数据",倒数两个字节为一些附加数据,最后一个字节为校验码,整个数据结构的长度为:(1+4+4+数据区长度+2+1)个字节。发送方填充完整个数据结构,然后发送给接收方,接收方接收到数据后,按照已知的数据结构格式去解析获得其中的原始数据。发送"文本消息"的示例代码如下:
1 //Code 2-11 2 3 public static void SendStringMsg(int sequence, string msg) 4 { 5 byte[] msg_buffer = Encoding.Unicode.GetBytes(msg); 6 byte[] send_buffer = new byte[12 + msg_buffer.Length]; // NO.1 1 + 4 + 4 + N + 2 + 1 7 using (MemoryStream ms = new MemoryStream(send_buffer)) 8 { 9 using (BinaryWriter bw = new BinaryWriter(ms))10 {11 bw.Write((byte)1); //NO.212 bw.Write(sequence); //NO.313 bw.Write(msg_buffer.Length); //NO.414 bw.Write(msg_buffer); //NO.515 bw.Write((short)0); //NO.616 bw.Write((byte)0); //NO.717 }18 }19 //send ‘send_buffer‘ to receiver with socket... NO.820 }
如上代码Code 2-11所示,首先定义一个发送缓冲区(NO.1处),因为12个字节已固定,所以缓冲区的长度应该是:12+文本消息长度,然后依次将消息类型(NO.2处)、顺序号(NO.3处)、数据长度(NO.4处)、文本消息内容(NO.5处)、附加字(NO.6处)和校验码(NO.7处)写入缓冲区,发送方按照预定义格式填充字节流缓冲区,再将其发送给对方(NO.8处);对应的,接收方接收到数据后,按照预定义格式解析字节流。下图2-13显示顺序号为10,文本消息为"ABC"时发送缓冲区send_buffer中的内容:
图2-13 发送缓冲区中的内容
注:在TCP通讯中,由于数据是以"流"的形式传递的,前后发送的数据连接在一起,接收方无法区分单个的消息(找不到消息边界),若按照上面提到的预先定义一个传输协议,接收方可以按照该协议解析出一条完整的消息。详细参见本书中后续有关"网络编程"的第九章。
不仅网络通信需要"协议"的辅助,计算机世界中还有很多场合需要"协议"的辅助,如加密和解密、编码和解码以及CPU执行机器指令、计算机通过USB口与外设交换数据等,下面表2-2显示了各种场合中的"协议":
表2-2 各种场合中的协议
序号 | 场合 | 协议 | 说明 |
1 | 加密/解密 | 使用的同一套算法 | 加密和解密的算法必须配套,否则会解密失败 |
2 | 编码/解码 | 使用的同一种编码规范 | 如各种编码规范:Unicode、UTF-8、Ascll,编码和解码必须使用同一套规范,否则会出现乱码 |
3 | CPU执行机器指令 | CPU和编译器使用的同一套CPU指令集 | CPU和编译器必须使用同一套指令集,传统编译器将高级语言直接编译成与平台相关的机器码,机器码只能在指定平台上运行,CPU和编译器须遵守同一个规范 |
4 | USB接口 | 计算机和外设使用的同一种USB规范 | 计算机与外设必须使用同一种USB规范,如USB1.0、USB1.1或USB2.0,否则两者之间不能正常交互(不考虑兼容情况) |
到目前为止,我们讲到的"协议"都能很好地跟现实关联起来,或者说,它们都跟协议字面意思接近。其实在.NET程序开发过程中,也有一种"协议",它便是使用关键字interface声明的接口。使用interface声明的接口也是一种"协议",它规定了代码调用方与代码被调用方共同遵守的一种规范,前面说过,代码中Client端与Server端需要交互,那么只有双方共同遵守某一约定,工作才能正常进行。这种协议在代码中具体体现在:
1)调用方必须存在一个接口引用;
2)被调用方必须实现该接口。
具体示例代码见Code 2-12:
1 //Code 2-12 2 3 interface IWalkable //NO.1 4 { 5 void Walk(); 6 } 7 class People:IWalkable //NO.2 8 { 9 public void Walk()10 {11 //…12 }13 }14 class Program15 {16 static void Main()17 {18 IWalkable w = new People();19 Func(w);20 }21 static void Func(IWalkable w) //NO.322 {23 w.Walk();24 }25 }
如上代码Code 2-12中,NO.1处定义了一个协议(接口),被调用方(NO.2处)遵守了该协议(实现接口),调用方也遵守了该协议(NO.3处,包含一个接口类型参数)。双方都遵守了同一个协议,才能协调好工作。下图2-14显示了"协议"在代码调用中起到的作用:
图2-14 代码调用中的协议
注:代码中使用interface声明的"接口"在面向抽象编程中起到了非常重要的作用,详细参见本书第十二章。
2.14 本章回顾
本章共介绍了13个将在本书中遇到的概念(术语),或许我们曾经了解过某些概念的含义,但一直处于似懂非懂的状态,那么阅读完本章,你肯定会拍下脑袋,高呼:原来是这样!有些概念在其它地方几乎找不到准确的解释,比如"线程和方法的关系"、"库与框架区别"以及"代码中的协议"等等;另外一些概念虽然能找到一些解释说明,但并没有像本章讲得这么详细。总之,本章定会扫清我们在编程道路上遇见的虐心绊脚石。
2.15 本章思考
1.下面代码Code 2-13中MyContainer类中的_int_list成员是否是线程安全的,为什么?
1 //Code 2-13 2 3 class MyContainer 4 { 5 List<int> _int_list = new List<int>(); 6 public void Add(int item) 7 { 8 _int_list.Add(item); 9 }10 public int GetAt(int index)11 {12 return _int_list[index];13 }14 }
A:不是线程安全的,因为无论是MyContainer.Add()方法还是MyContainer.GetAt()方法,均可以同时在多个线程中运行,这就意味着可能存在多个线程同时访问集合容器_int_list,可以在MyContainer.Add()以及MyContainer.GetAt()方法中加上锁(lock(object))来解决该问题。
2.举例说明实际开发过程中遇见的框架和库有哪些。
A:框架有:Asp.NET MVC、Asp.NET Webforms、Windows Forms、WCF、WPF以及SilverLight等;库包括公司内部一些通用库,如MySQL数据库访问工具库、日志记录工具库、字符串处理工具库、图片处理工具库以及加解密工具库等等。
(本章完)
高屋建瓴:梳理编程约定