局部性原理
程序的局部性原理是指程序在执行时呈现出局部性规律,即在一段时间内,整个程序的执行仅限于程序中的某一部分。相应地,执行所访问的存储空间也局限于某个内存区域。
局部性原理又表现为:时间局部性和空间局部性。
时间局部性是指如果程序中的某条指令一旦执行,则不久之后该指令可能再次被执行;如果某数据被访问,则不久之后该数据可能再次被访问。
空间局部性是指一旦程序访问了某个存储单元,则不久之后。其附近的存储单元也将被访问。
这一规律是是普遍事实的总结,更是许多计算机技术的前提假设,比如.NET中托管堆以及代龄的处理过程,便是基于这个认识。
之所以有这个规律,很多人认为原因是:程序的指令大部分时间是顺序执行的,而且程序的集合,如数组等各种数据结构是连续存放的。对于这一点,我个人表示赞同。
程序的局部性原理是如此重要,以至于与程序设计的各个方面都存在密切的关系。
局部性与效率
熟悉代码的局部性原理,并且按照这个思路去写代码,可以显著的提高代码的执行效率,先看下面的C#代码:
static void Main(string[] args) { int[,] a = new int[10000,10000]; int sum = 0; // 按照先行后列的顺序遍历二维数组,这是正常做法 WriteTimes(() => { for (int i = 0; i < 10000; i++) { for (int j = 0; j < 10000; j++) { sum += a[i, j]; } } }); // 按照先列后行的顺序遍历二维数组,这是异常做法 WriteTimes(() => { for (int j = 0; j < 10000; j++) { for (int i = 0; i < 10000; i++) { sum += a[i, j]; } } }); Console.Read(); } static void WriteTimes(Action func) { DateTime dt0 = DateTime.Now; func(); DateTime dt1 = DateTime.Now; Console.WriteLine((dt1 - dt0).Milliseconds); }
大家可以输出一下,在我的机器上的输出为(具体的数值可能不同,但是大小比例应该差不多):
102 999
这个例子本身并没什么实际的意义,但如果处理的数据量足够大,并且可能需要频繁的在外存、内存、缓存间调度,又注重效率的话,这个问题就有可能会被陡然放大了。不过,通常来说,效率总是在程序出现性能问题后才应该被关注的方面。
局部性与缓存
归根结底,缓存(各种缓存技术,CPU缓存,数据库缓存,服务器缓存)探讨的也基本都是效率的问题,看另一个来源于网上某位仁兄的问题:
// 写法一:循环内塞进好几件事 for (int i = 0; i < 1000; i++) { WriteIntArray(); WriteStringArray(); } // 写法二:循环内只干一件事 for (int i = 0; i < 1000; i++) { WriteIntArray(); } for (int i = 0; i < 1000; i++) { WriteStringArray(); }
问:两种写法哪个好?
有的同学认为写法一效率高,因为循环只执行了一遍,而有的同学认为写法二效率高,因为该写法中每个循环内的局部变量大部分情况下是比写法一少,这样更容易利用CPU的寄存器以及各级缓存,这满足局部性原理,所以效率较好。
我写了简单的程序验证了一下,发现确实有时候写法一执行时间较短,有时候写法二执行之间较短,没有明显的固定规律,所以我认为这里的效率一说不太明显,当然了也许是这里的循环次数比较少,循环多次的低效还没有体现出来,感兴趣的可以自己试一下大的循环。
即使是这样的结果,我还是倾向于使用第二种写法,这不是效率的原因,而是重构中,提倡一个循环只干一件事。
局部性与重构
重构的基本原理这里就不多说了,感兴趣的随便搜一下就可以了。重构的基本原则中就有诸如:一个循环只干好一件事,关联性强的代码放到一起,变量定义在使用的地方等等。这些原则与局部性原理阐述的规律竟然是如出一辙。
看一些我认可的写法:
// 一个循环内只干好一件事 for (int i = 0; i < 1000; i++) { WriteIntArray(); } for (int i = 0; i < 1000; i++) { WriteStringArray(); } // 原始的代码 List<int> salaryList = new List<int>(); List<int> levelList = new List<int>(); List<int> scoreList = new List<int>(); collectHighSalary(salaryList); collectHighLevel(levelList); collectHighScore(scoreList); collectMiddleSalary(salaryList); collectMiddleLevel(levelList); collectLowSalary(salaryList); collectLowlevel(levelList); // 重构成: // 有关系的代码放到一起 // 变量需要时再定义 List<int> salaryList = new List<int>(); collectHighSalary(salaryList); collectMiddleSalary(salaryList); collectLowSalary(salaryList); List<int> levelList = new List<int>(); collectHighLevel(levelList); collectMiddleLevel(levelList); collectLowlevel(levelList); List<int> scoreList = new List<int>(); collectHighScore(scoreList);
局部性原理不仅与语句和函数的组织方式息息相关,还与组件的组织方式互相呼应。
局部性与高内聚
从元素(函数,对象,组件,乃至服务)设计的角度,内聚性是描述一个元素的成员之间关联性强弱尺度。如果一个元素具有很多紧密相关的成员,而且它们有机的结合在一起去完成有限的相关功能,那这个元素通常就是高度内聚的。高内聚的设计是一种良好的设计。
耦合性从另一个角度描述了元素之间的关联性强弱。元素之间联系越紧密,其耦合性就越强,元素的独立性则越差,元素间耦合的高低取决于元素间接口的复杂性,调用的方式以及传递的信息。低耦合的设计是一种良好的设计。
一个具有低内聚,高耦合的元素会执行许多互不相关的逻辑,或者完成太多的功能,这样的元素难于理解、难于重用、难于维护,常常导致系统脆弱,常常受到变化带来的困扰。
毫无疑问,遵循良好的局部性原理通常能得到良好的高内聚低耦合元素,反之,代码中元素的高内聚低耦合也使的局部性得以大大加强,此所谓相得益彰。
局部性与命名
说到命名,不得不提著名的匈牙利命名法。
在我读了《软件随想录:程序员部落酋长Joel谈软件》一书之前,我认为的匈牙利命名法则就是在驼峰式命名的基础上,在变量名前加上变量的类型,例如iLength表示int型的表示长度的变量。
但是在我阅读了《软件随想录》一书相关的章节以后,才彻底的了解到其中的误解。原来微软那位大牛推荐的匈牙利命名法居然不是我想的那样。
在该书中,作者将匈牙利命名法分为两种,流行的并且被废掉的叫“系统型匈牙利命名法则”,这种命名法将变量类型加到了变量名字前面,老实说确实没什么意义,特别是在现代编辑器中。
事实上,微软那位仁兄推荐的是叫做“应用型匈牙利命名法则”的规则,那就是把变量的应用场景加到变量的名字前面。
比如在页面开发中,直接从用户输入得到的Name字符串可以起名叫:usName,其中us代表unsafe,意思是这个字符串是用户输入的,没用经过编码处理,可能是不安全的。而经过编码的Name字符串可以起名叫sName,其中s代表safe,意思是这个字符串经过了编码处理,是安全的。
谈到命名的规则,就是为了说明下面这个息息相关的问题:代码错误检查。
代码错误检查也是一个经典的话题,如何让代码的错误提前暴露出来,而不是发布后由客户去发现,这是个问题。
满足局部性原理,使得我们的程序内聚性通常很好,但是毫无疑问,有些元素还是必须要贯穿很多的行的,比如在某些函数中,定义变量和末次使用变量的地方可能相差几十行:
var usName = getName(); action1(usName); // 此处省略20行... sName = usName; // 此处省略10行... document.write(sName);
我不得不承认,Joel老兄提出的“应用型匈牙利命名法则”还是相当有作用的。比如中间那行:
sName = usName;
我们很容易就会从变量名发现这行代码存在安全性威胁。
我们其实生活在世界的局部中
推而广之,局部性原理不仅仅是适用于程序的理论,而是适用于我们生活的各个方面的重要规律,它的称呼向来随着场合的不同而有所变化,比如有时叫“习惯”,有时叫“惯性”,有时又演化成“熟悉的人/事”等。总之,我个人认为,人总是倾向于在局部的、连续的时间空间内做相关的、熟悉的事情,程序其实是人类做事风格的反应。