读《冒号课堂》

read books

Posted by 宸笙 on February 1, 2019

副标题《编程范式与OOP思想》,2016年在做BmobSDK的重构的时候同时也在学习提升对代码的理解与认知,然而这些相对小众,能看到的资料也是不多的,后来看到一个博客偶然发现了这本尘封多年早已被很多人遗忘了的好书,着实是我很大的收获;其实在09年前后有很多很不错的书(个人觉得其实读一些旧一点的早期的书能更好理解对应技术的初期后后续演变),java后端领域或.net比较突出,好像百家争鸣,各有千秋,后来看到同时期一些书也有类似的感受,可惜这本书最后还是在市场面前因为小众或者说宣传少吧绝版了,后在知乎上看到一个阿里的朋友说他也想找这本书,这两年来如果朋友说要提升下我基本都会推荐这本书,至少看完对自己有所启发,对编程的大体思路会更清晰,而不仅仅是所谓一招一式的那种知识,而更难得的是数学出身的作者除了思路严谨外还兼顾较好的文笔!

main

  • 有的书籍确实是既不深入也不浅出;
  • 学会->会学->会用->被用
  • 学会(知其所然):掌握一些具体编程知识的初级程序员;
  • 会学(知其所以然):能快速深刻理解技术并举一反三(迁移)的程序员;
  • 会用(人为我用):能将所学灵活运用到实际编码设计中的高级程序员;
  • 被用(我为人用):能设计广为人用的应用程序(application),库(library),工具包(took-kit)或框架(framework)的系统分析师架构师(能力加成和平台机遇造就);
  • 语言分代:

    第一代: 机器语言 第二代: 汇编语言 第三代: 高级语言(Fortran,Pascal,C,Java VB…) 第四代: 面向问题语言(SQL,SAS,SPSS) 第五代: AI语言(Prolog,OPSS)

  • 后两代与前面的区别在于重目标轻过程(描述问题),重描述轻实现;
  • 编程范式和编程语言,抽象和具体实现;一些主张和观点表现在于语言的核心概念,方法论表现于语言的表达机制,而同一语言可以有多种范式如scala同时支持面向对象函数式
  • 对开发技术的认知–实用还是时髦?
  • 概念和技术并不孤立,如果不能较全面理解便好比摸象之盲人,各执一端且自以为是;
  • 经典的区别:区分library/tool-kitframework

    调用关系上,程序员写的代码调用library/tool-kit,而却被framework调用;

    在使用的直观感受上,前者给程序员带来了自由,后者给程序员带来约束

  • 理解编程范式,增强编程语感,只有当自己理解之后写出来的才是地道的比如OO风格或函数式风格的代码;

    基于现代的软件分工,我们常常会需要把一些整体流程和共性的步骤先开发完成,具体的留一些hook勾子给业务程序员完成,他们便不用从0思考整体流程了,先开发的便是framework;而在具体实现业务的过程中,可以调用一些现有的library,他们屏蔽了底层细碎的Api调用,同时也让应用解耦和功能复用成为了可能,共性的功能实现只需开发一遍,故而framework主要为了设计重用,而library则是为了实现代码重用;

  • 常用范式
  • 泛型编程,解决了因类型不同导致的共性代码无法抽取复用的问题;
      // generic programming in C++
      // 把算法抽出以供复用
      // 基于模板template的参数多态,区别于OOP基于继承实现的子类型多态,普适性更强
      // 分离数据结构(类型)和算法,使后者最大程度实现复用
      // 常见需求,max maxLong maxFloat maxDouble?
      // 宏定义无法确保类型安全,但模板兼顾类型安全和代码重用,编译期展开
      template <typename T>
      T max(T a,T b){
          return (a > b)?a:b;
      }
      // macro define in C
      #define max(a,b) {(a) > (b)?(a):(b)}
    
  • java引进泛型的原因:int等原始类型并非Object的子类,且编译期无法进行严格的类型检查;
// C++ STL 省去T 元素被容器封装
template <class Iterator,class Act,class Test>
void process(Iterator begin,Iterator end,Act act,Test test){
    for(;begin!=end;++begin){
        if(test(*begin)) act(*begin);
    }
}

* 超级范式--metaProgramming
* **元编程**--提升语言的级别,讲程序作为数据对待

template <int N>
struct factorial{
    enum {value = N * factorial<N-1>::value};
};
template <> // speciallization
struct factorial<0> 
{
    enum {value = 1};
};

void main(){
    // 区别在于是通过编译时还是运行时计算出来的
    // same as cout << 120 << endl;
    cout << factorial<5> :: value << endl;
} 
  • 元编程的使用场景:

    DSL

    IDE生成代码

    UML工具转化类图为代码

    Servlet引擎将JSP转为java代码

    Spring,Hibernate,XDoclet的很多框架和工具根据配置文件,annotation/attribute生成代码;

    除了上述,平时业务开发就能用到的例子:生成重复代码;

    生成代码偏向编译期,而一些动态脚本语言如ruby或python能在运行时修改程序代码,也称为动态元编程,也提供了很多如eval()方法在运行时将字符串作为表达式(代码)来计算和运行;

  • 切面范式–换个角度看问题
  • AOP的基础–SoC和DRY

    SoC,Separation of Concerns(关注点分离)

    DRY,Dont Repeat Yourself

    Aspect,描述横向关注点,场景是在不同的角落都有一些重复的代码片段,无法通过传统的方式实现模块化,典型如性能检测,代码追踪,事务管理等代码,AOP技术把每类横切关注点封装到单独的Aspect模块中,讲程序中的执行点和对应的代码绑定起来,单个的执行点称为切入点join point,例如调用某个对象的方法前后,符合预先指定条件的接入点的集合成为join point,或所有以set为命名开头的方法,每段绑定的代码称为一个建议advice。

    区别于OOP,OOP只能沿着继承树的纵向方向复用,而AOP弥补了OOP的不足,能在横行方向上实现复用;

    AOP的机理:实现的关键在于将advice的代码潜入到主体程序之中,就是所谓的weaving编织;进一步说,编织分为静态和动态两种;静态指修改源码或字节码在编译期嵌入代码,动态则是通过代理等技术在运行期实现嵌入,具体的工具如AspectJ,Spring等;

    AOP的实施步骤:切面分解,切面实现和切面合成;

    概念的关系:接入点是附加行为advice建议的执行点,切入点pointcut是指接入点pointcut的集合,这些接入点共享一段插入代码,pointcut + advice = aspect切面,是模块化的横向关注点;

  • 事件驱动,通知的典型场景,是主动轮询还是被动等待通知;
  • 事件,表现为程序中某个状态的变化,基于事件驱动的系统一般提供两类内建事件(build-in event),一类是底层事件原生事件,另一类是语义事件,代表用户的行为逻辑,是若干底层事件的集合;而之外的便是程序员基于业务场景定义的用户自定义事件

      // WIN32 Application
      _WinMain(...){
          // 1 注册窗口类别
          windowClass.lpfnWdnProc = WndProc;// 指定该类窗口的回调函数
          // 指定该类窗口的名字
          windowClass.lpszClassName = windowClassName;
          RegisterClassEx(&windowClass);
          // 2 创建一个上述类别的窗口
          CreateWindowEx(...,windowClassName,...);
          // 3 message loop 消息循环
          while(GetMessage(&msg,NULL,0,0) >0){
              // 翻译键盘消息
              TranslateMessage(&msg);
              // 分派消息
              DispatchMessage(&msg);
          }
      }
        
      // 4 窗口过程--处理消息
      _WndProc(...,msg,...){
          switch(msg){
              case WM_SIZE: ...;// 用户改变窗口尺寸
              case WM_MOVE: ...;//用户移动窗口
              case WM_CLOSE: ...;// 用户关闭窗口
          }
      }
    
  • 消息事件
  • callback,回调机制,C/C++中通过函数指针实现,另外抽象类,接口和C#中的delegate也能实现callback;
  • 普通函数回调函数

    main applicaiton -> plain function -> lib function

    main application -> lib function -> callback function

  • 上层模块一般为调用者去调用下层模块,但有时下层模块为了追求更好的可扩展性,就有了反向调用上层模块的需求;

      String[] strs = {"Please","sort","the","strings","in","REVERSE","order"};
      Arrays.sort(strings,new Comparator<String>(){
          // 该hook function由上层实现并由下层调用,这样就不局限于自然排序,甚至可以让用户自定义比较规则,提高算法的重用性
          public int compare(String a,String b){
              return -a.compareToIgnoreCase(b);
          }
      });
      // 反观前文win32的代码
      // msg loop的代码可以提取出作为library的一部分
      // 区别在于Comparator的回调是同步回调,事件例子的回调属于异步回调
        
    
  • 异步回调,更将调用者和被调用者从时间上解耦;
  • 好莱坞原则,dont call us,we will call you (back). 符合IOC;
  • 多态机制,一种是利用GP的参数多态,一种是利用OOP的子类型多态;区别在于绑定的时机(早绑定or晚绑定);
  • 从继承,interface到mixin(trait);
  • 普通类(成品)vs抽象类(半成品)vs接口(模具);
  • 值类型引用类型,一些困惑源自于屏蔽了内存管理,利于降低学习门槛,避免内存泄露等,但也失去了对底层操作的自由并对一些概念不太好理解;
  • 内存分配策略:静态分配栈分配堆分配,静态分配在编译期,在静态内存区为全局变量,静态变量,常数变量等;后两种策略发生在运行时,但栈分配一般在编译期就能确定待分配内存的大小和生命周期,堆分配则推迟到运行时;
  • 自动变量,auto修饰,在C++中区别于静态区的局部变量;
  • stack vs heap
内存机制 分配方式 释放方式 总容量 生命周期 时空效率 线程安全
空间大小和生命周期在编译期决定 自动释放 较小 较短 较高
空间大小和生命周期可在运行时决定 手动释放或通过垃圾回收 较大 较长 较低
  • 引用类型所指的对象不一定在堆上,在java和C#中成立,但在C++中,可以在栈上(可以在栈上创建对象引用);
  • 值类型对象总在栈中?No,如果嵌在引用类型对象中,则回到了上一个问题;
  • 引用和指针;
  • 在java中,无法自定义值类型如struct,C#中可以,则引用类型对象所在区域为栈;
    // 分配在堆上(C++)
    SomeType* a = new SomeType();
    // 分配在栈上(C++)
    SomeType a = SomeType(); // SomeType a;
  • 函数调用过程中的参数传递,传值(pass-by-value)还是传引用(call-by-reference)?
  • 在java中是传值还是传引用?
class TestPassByRef{
    static void change(String str){
        str = "new value";
    }
    
    public static void main(String[] args){
        String s = "old value";
        change(s);
        // 如果是传值,那么应是new value but结果是old value
        // 和String是不可变无关,换为StringBuffer同样
        sout(s);
        // 那么java如何修改引用对象的内容?
        // 在引用变量作为参数按值传递后,方法体获得了对象引用的拷贝,与原引用对应同一对象,所以可以操纵原对象;
        // 亦即:java的方法尽管不能改变引用原件(即不能为其重新赋值),但仍可通过引用复件来改变对象的内容
    }
}

// in C++
static void change(String& s){
    s = "new value";
}
// in C#
static void change(ref string s){
    s = "new value";
}

// 同样的change方法,在java中收到的是对象引用的值,在C++中收到的是对象的引用,在C#中收到的是对象的引用的引用;
  • 引用的引用,C++的string是值类型,加&表示传的是string对象的引用;C#的string是引用类型,加ref表示是引用的引用,而非引用的拷贝;
  • 为何java不能按引用传递?按引用传递会减少对象复制(传值时产生临时的复制对象),在效率上是优于按值传递;但Java中本身所有对象都是引用类型,对象本身并不会被复制,没有按引用传递的机制无大碍;
  • 当一个对象给一个变量赋值或作为参数按值传递时,在C++中复制的是该对象的值,而在java复杂的是对象的引用,所以C++中有专门的赋值(拷贝)运算符和复制构造函数,但java没有;
  • 但,java中如何复制对象呢?只能显示重新构造对象(new)或通过clone序列化等手段;
// 如果是值类型,一次性为1000个对象分配了内存
// 如果是引用类型,只为1000个对象引用分配了内存,每个具体的对象的内存分配是额外的
// 1 花更多的时间 
// 2 由于分配空间的不连续性造成的内存碎片增多,浪费空间也浪费读写时间(不连续的空间违背了局部性原理,cache miss);
SomeType[] a = new SomeType[1000];
  • 引用也是实现多态的前提?

    在java中的对象都是通过引用来操纵;在C++必须通过指针或引用才能表现出多态特征;在C#中值类型更不能直接被继承,谈何多态;

    why? 对一个不通过引用而被直接操作的对象而言,多态是没必要的,具体类型在编译时就被确定;而从客观的内存角度来说,也难以实现多态,分配的空间也难容比他更大的子类型对象;

  • 引用对比

  存储内容 逻辑指代 实际数据 价值属性 对象复制 对象共享 实现多态 空值
数据 直接 online in 赋值
引用 地址 间接 offline out 克隆
  • 引用的灵活性:

    避免不必要的值拷贝

    实现对象共享

    容易实现递归结构

    实现多态

    可null(dummy-object-pattern)

  • 等价判断
// 值变量v1和v2相互独立
ValueType v1 = someValue;
ValueType v2 = v1;
// 引用变量r1和r2互相关联
// 亦即,修改r2会影响到r1 
// 别名(aliasing)
ReferenceType r1 = someObject;
ReferenceType r2 = r1;

// 值类型的ValueType具有引用语义(C++)
ValueType v1 = someValue;
// 方法1:通过引用让v2成为v1的别名
ValueType& v2 = v1;
// 方法2:通过指针让v3指向v1
ValueType* v3 = &v1;

// 引用类型的ReferenceType具有值语义(假定是java的Cloneable类)
ReferenceType r1 = someObject;
// 避免r1与r2共享同一对象
ReferenceType r2 = (ReferenceType)r1.clone();

// 问题来了,如何准确判断一个类型的语义呢?
// -> 对象的复件能否取代原件,是则为值语义,否则是引用语义;该判断方式无关乎语法,纯粹取决于设计者的意图;
  • 从命令式编程的角度看,一个值语义变量的内存地址是无关紧要的;
  • 从函数式编程角度看,值应该是引用透明(reference transparency)的,即一个表达式随时可以被值替换;
  • 从OOP角度看,值语义和引用语义的区别在于对象标识(object identity)的重要程度;

    如java和C#中的String类虽是引用类型,但却是值语义的,因为开发者关心的是字符串的内容而非内存地址(==只能比较引用,equals才能比较内容),所以C#中直接重载了String的相等运算符;

  • more,值语义类型一般都是不可变的

      // in java
      String s1 = "ab";
      // s2的内容是"ab"
      String s2 = s1;
      // s1与s2指向同一对象
      assert(s1 == s2);
      // s2的内容是"abcd"
      s2 += "cd";
      // s1和s2指向不同对象
      assert(s1 != s2);
    
  • java的String类为何要设计为不可变的?

    如果不是,会有别名问题,比如修改s2后s1的内容受影响,设计为不可变的则省去了之前ReferenceType的显示克隆过程,所以引用类型通过不可变类的方式实现了值语义

  • C++的string为何是可变的?

    由于是值类型实现,另外也可以加const折中;

  • java和C#中的Boolean之类的包装类型为何是不可变的?

    基础数据类型是值类型,但有时候需要以引用的类型的方式出现(比如泛型),亦即boxing的过程;

    但从语义角度说,装箱后的对象虽为引用类型,但仍为值语义,所以需要为不可变的

  • 那么,一个可变的引用类型还能实现值语义吗?

    java的Date类,要实现其值语义,只能靠手工来弥补,即在日期的传递或赋值时进行防御性复制(defensive copying),如在getter方法返回对象的日期属性前在setter方法勇传入的日期参数赋值之前,都拷贝一份对象;

    Date设计为可变类型是为了减少对象的创建,但是结果却事与愿违–重复的防御性复制也许会创建更多的对象;

  • 数据库表,每条记录都有对应的主键(primary key)作为唯一标识,故而记录有引用语义,而表中的属性或字段则具备值语义,两者是正交的,而对象具有引用语义,对象的字段具有值语义,所以数据模型(data model)与对象模型(object model)在这个程度上契合,ORM也由此而来
  • OOP的持久化类(persistent class)与RDB(relational database)的表就能对应;
  • 引用标识与内存地址,引用不等于内存地址。C++的引用通过指针实现,指针即内存地址,但java和C#中的引用一般不是原始地址,而是opaque pointer(不透明指针),保证内存的安全性;
  • 在ORM中就复杂了,相同主键对应的对象的内存地址就相同吗?No;
  • UML

    关联关系:has-a

    聚合是一种强关联,强调整体和部分,owns-a关系;

    合成是一种强聚合,contains-a的含有关系

  • 设计原则

    间接(Any problem in computer science can be solved with another layer of indirection.)

    例子:文件系统的路径path,HTTP中的URI,数据库中的外键foreign key,程序设计中的变量等;

    变量是对内存的间接,函数是对计算过程的间接或抽象,抽象的意义在于一方面掩盖具体细节提高简洁度,也有了明确的语义提高清晰度;可维护性也明显提升。

    一个好的间接(中间)层应该是怎样的?比如在OS中,处于安全的考量,应用程序一般无权直接与硬件交互,只能通过OS核心–kernel内核间接访问底层资源;内核作为硬件也应用程序的中介,提供了抽象的API接口,使得应用程序能借助系统调用(system call)向其请求服务;而JVMCLR之类的vm也当属此类,在OS上提供了新的间接抽象层;

  • DIP,高层和底层模块都应依赖于抽象;
  • 场景:数据库应用,因数据库系统开发商各自为政,提供的API复杂晦涩且差异较大如果程序通过这些API访问DB,代码将繁杂且难以维护,所以在应用程序和数据库API间铺一层中间模块,让抽象层的两侧(应用程序和数据库开发商的API)都遵守改层的规范,有效约束的两边的变动同时也简化了开发和维护的难度;
  • jdbc的做法:
// 应用模块
Class1 Class2 Class3 Class4
// jdbc API
// 上层为具体类
DriverManager Date Time Timestamp Types SQLException
// 下层为接口 需要数据库开发商去遵守
Driver Connection Statement ResultSet
// 数据库开发商实现 如sql的jar
// 底层模块如sql的Dirver等依赖了jdbc(属于高层模块)的API,和前述的依赖方式整好相反,故为依赖倒置;
Driver implements Driver{}
ConnectionImpl implements Connection{}
StatementImpl implements Statement{}
ResultSetImpl implements ResultSet{}

  • DI,强调依赖的来源来自外部;
  • DI容器,spring,guice等;
  • DI是IOC的一种实现方式
  • protected variations,找出预计的变化或不稳定点,用稳定的接口来包装;
  • design patterns
  • 构造器的弊端到factory pattern,Boolean的valueOf方法,推荐用静态工厂提供,见《effective java》;
  • bridge,分离接口与实现,可以是预先的设计或是refactor后(两个使用dps的时机),处于代码可维护的考量;
  • adapter一般常是事后的补救,更多处于可重用的动机,比如新老接口的衔接适配,refactor遗留代码等;
  • 对既有object的一种包装(wrapper),decoractor,继承+组合,明显解决子类数量膨胀问题;
  • proxy,跨进程或远程方法调用RMI中,常返回一个代理对象,见于Android的Bindler类似的IPC方式;
  • pipeline patterns,在Spring中的filter场景,在客户端框架中的事件拦截捕获(冒泡)场景,也称chain of responsibility patterns
  • 解耦sender与receiver–command pattern
  • 两种对dp的初衷的理解:

    如果对应到交流的语言,编码亦即用母语交流,那么dp类是一种简洁的成语化的表达方式,比如可以没有singleton,但是代码中会多出很多判空的冗余代码;

    在编码和语言初衷的角度,毕竟有些语言在设计时的历史背景和作者意图(应用的领域和面向的用户定位)的差异,在表达不同领域的需求上,语言的表现力总有差异,比如是否天然支持函数式等,函数是否是first class,所以我们也常常会有类似在java中实现promise风格的写法,在java如果实现响应式编程等需求,这些都是可实现的,这些便是对语言在某方面的支持不足而做的扩充和弥补;而另一方面,语言显然是有一些bug的,比如java在new操作的原子性问题,如果没有该问题,那么singleton实现中的DCL方式也就没有了必要;当然主流的观点都偏向于说dp很大程度上是对语言的一种扩充和弥补;

    如克服new和构造器的局限(重载和表达意图不明显–方法名固定,无法返回多态类型,必须创建实例等)就有了工厂相关的patterns;

    如克服内存分配相关的麻烦问题,就有了flightweight pattern;

    如克服函数不是first class的局限,便有了command pattern,其实这个角度和另一观点可以调和;

    为了解决对象通信的麻烦,有了filter,observer pattern等;

    为了解决OOP的单分派single dispatch问题,就有了下文所述的visitor pattern

  • 换个角度理解对象的方法的调用过程,受限于科班学习概念的灌输的日常的编码习惯,我们总会理解为方法的调用就是对象的成员函数的调用,其实这些都是语言带给我们的理解业障;在其他语言中便直接抛出了类似的观点如io语言,其实可以换一种上帝视角,方法就一定属于对象的某个角落的吗,不妨砍成调用方法的过程,就是向某对象发消息的过程,参数亦即消息值,而对象就是消息的接受者,收到后便要做出响应,我们的直观感受就是对象的成员函数被调用了,其实在其他动态脚本语言中,这都是很常见的;

  • dispatcher,方法调用时的分派;分派亦即为一个调用点(call site)确定相应调用方法的过程;
  • 在编译期决定的分派是静态分派,如重载多态和参数多态,反之则是动态分派(C++,java和C#等采用的是同样的动态分派机制),在运行期根据一个方法的接受者(即方法所属的对象)的实际类型来决定要调用哪个方法,因为这类分派仅取决于一个类型(即方法的接受对象的类型),故称单分派;

  • 为何方法重载overload依据的是参数类型,而方法覆盖override依据的是实例类型呢?

    因为方法的overload发生在同一个类中,只能靠方法签名来区分;而方法的override发生在不同的类中,方法签名一样,只能靠实例类型区分;

    不全是;从编译器角度看,在进行方法分派时是同时考虑方法的参数类型和实例类型的,区别在于对前者的考察是在编译后结束而后者的考察要延续到运行期;因为这种迟绑定late binding带来了多态机制;而现在问题转为:为何在运行时只考虑对象类型的差异而忽略了参数类型?

    换个角度,实例也是方法的一个参数;理解的障碍在于OO带来的障眼法:比如a.doSth(1); 其实可以理解为 doSth(a,1);后者显然是过程式的写法,但是对编译器而言,OO和面向过程或许差别真没那么大,回到笔者的里另一笔记的观点,OO只是随着历史背景为解决日益庞大的软件规模增速和低下的开发和维护效率的矛盾推出的银弹,面向的是开发和程序员;

    比如两个集合对象在做交集运算代码为:

      // 过程式
      intersect(set1,set2);
      // OOP
      set1.intersect(set2);
      // 那么:既然set1和set2是平等的,共享方法intersect,或说如果在编译时无法确定intersect的实现代码,那么就在运行时由set1和set2两个对象的实际类型共同决定;此为多分派;
    
  • visitor pattern,用两次单分派在java上实现了双分派(double dispatch),且能在不修改一个聚合体的前提下提供新的作用于其元素之上的操作,如新增新的visitor;
  • 异常处理机制便是一种chain of responsibility pattern
  • coding -> OOP -> reusebility -> design patterns;
  • coding -> 编程范式 -> 可维护性 -> 设计原则;

  • 本书一些太偏重于思想层面,容易被人说到机锋味浓,概念的东西过重,不过还是需要反思是否自己年限和经验还没足够去理解作者的深意;
  • 至少对一些抽象的理解在第二次重读之后自己理解是加深了;
  • 需要多总结并反思提升自己的悟性,不管是内在的功底还是外部的编码招式技能;
  • 随着工作年限的增加,慢慢会进入到一个做需求和改bug都比较顺畅的舒适区,需要加深对一些司空见惯的编码细节的内在理解,难的并不是技能的学习和掌握,是在更进一步层次认知的提升,而这一步提升了,编码实现就很快了,比如怎么去实现一个响应式的java类库,我们常见的一些流行三方库的概念都是作者在头脑中沉淀的隐形思考(无法直接逆向揣度),怎么实现是第二步(我们看到的开源代码),而大部分的开发者还是在学习这些API怎么使用的层次,可想而知需要提升的还有很多