关于软件设计的一些问题

2022-02-22 21:41:53 +08:00
 lanlanye

不知道各位写代码时会不会经常觉得遇到以下问题:

  1. 某个听起来很自然的功能实现起来却需要绕很大一圈。
  2. 某个功能的实现不可避免的会影响到许多不相关的功能。
  3. 迭代到某个阶段,软件的响应越来越慢,逻辑越来越复杂,开发人员已经搞不懂自己在做什么了。

...

以上只是我遇到许许多多问题中的一小部分,毕业快两年,我时常觉得自己写出的东西随着功能的增加离自己的设想越来越远,即使满足了业务需要也无法让自己满意。

我认为开发难以进行的根源往往不是因为开发人员不会写业务代码,而是设计上出现了不合理的地方,有时是因为初期没有时间仔细考虑,有时是因为不可避免的需要与其他项目耦合,然而归根结底是开发者的设计能力不足,没能让软件保持低耦合与高内聚。

近一个多月的时间,我试着通过各种渠道补充相关知识,从 数据库设计的一个小问题 到流行的架构模式和设计模式逐步学习,深感从 Python 开始开发一定程度上让我低估了面向对象设计的重要性。

以下是我最近读过或正在读的书单(按时间顺序):

  1. 《数学之美》
  2. 《 SQL 反模式》
  3. 微软公开的 Azure 设计模式说明
  4. 《实现领域驱动设计》
  5. 《设计模式》
  6. 《领域驱动设计·精简版》
  7. 《领域驱动设计·软件核心复杂性应对之道》

顺序上存在一些不合理的地方,比如《实现领域驱动设计》中提到了许多我难以理解的概念,这些概念是通过穿插着阅读后面几本书以及通过搜索引擎才逐渐能够理解的(以及我至今也没能把这本书彻底读完),这些书中也会引用其他人的著作,受限于时间我也没能逐个阅读,比如《分析模式》、《重构》等。

可是直至目前我依然有很多疑问:印象里不久前还经常被人提起的“函数式编程”有什么优势?如果面向对象设计是一种“最佳实践”,为什么后来诞生的语言没有特别强调自己面向对象的特性?

简言之,我隐约感觉自己遇到了瓶颈,我不确定这些疑惑是否会随着不断学习而得到解答,同时也很担心自己绕了远路钻了牛角尖,所以 希望站内的大佬们可以推荐一些相关书籍或者分享一些自己的学习经历、见解

另外,我建了一个 QQ 群,希望 和我有同样困惑或相似目标 的人能够加入进来共同交流进步。这是一个尝试,我希望它是一个单纯的技术交流群,没有日常寒暄和无意义水群,不会因为过多的消息被人屏蔽,如果实在做不到我也可能解散掉它,群号 OTAyMzE0NDEz

3862 次点击
所在节点    程序员
44 条回复
FrankHB
2022-03-04 13:16:43 +08:00
@3dwelcome 路走窄了。

海量 API ?海量用户和需求面前算什么事儿?

这里有个背景:用户和开发者从来没有绝对的界限。(真跑题:就因为这,我同时站在 RMS 和 Steve Jobs 的对立面。)
很多用户和开发者的角色已经重合。开发者水平够,需求响应就能几乎任意高效。开发技术演进使“水平够”的门槛不断降低。到一定程度,OP 的部分因需求理解困难的问题会通过这种耍无赖的方式解决(剩下是怎么都不太可能圆满解决的)。
长期看这是种 dssq:最终用户迟早会跨越信息不对等,变成某种意义上的程序员 /产品经理,自己动手才能保持具体需求上的话语权;跟文盲起码先得脱盲才可能融入主流社会一样。
(无法融入?最好祈祷需求真被谁代表了而能蹭点现有产品。有消费能力的,会被当作代表资本话语权的实体的提款机,用完就扔,还没本事阻止一些开发者耍流氓。这是现在进行时。)
能预见的阻止 API 发挥价值最终的问题就是领域特定逻辑太麻烦,非用户的开发者不够熟悉。以后要管海量 API ,大不了锅扔给同时是开发者的内行用户就是了。

API 就不是什么细节,而是所有接口中最具有代表性的。相比其它种类的接口,API 受需求变化和外部随机干扰更小,开发者最能主动自主变更设计应对需求变化,演化过程也最易自动化。
被认为是细节,说明现在技术发展很初级罢了,不够提供足以让用户信任的大规模可靠接口。
“一个是低维……两者没办法放一起对比的。”——这正好是 API 提供的抽象比起其它接口更加擅长专门治疗的不服。
生产力足够,UI 甚至 ABI 都能被吸收作为 API 提供,反过来不行。
设计 DSL 的工作也能是设计 API 的子集(调包侠拿工具搭积木;实现能更自动化),当然现在科技不够(最直接地,spec 绕不开自然语言这种自动化之敌)。

另外,让没被 API 吸收的其它接口设计工作有存在感,会越来越细碎和专业化,占据通用领域的开发者过大比例的资源而和生产方式不搭;之后,只有最终用户才能准确理解和快速响应依赖非通用 API 设计方案满足的需求。
传统开发者更适合作为专业顾问或者维护基础设施。这种正反馈加速生产力分工的分化和再融合。最后,用户会自发把需自动化照顾效率的其它接口标准化为 API 。

函数是 API 设计中能被普遍复用的单元,有资格成为未来所有能复用的接口的必备标准件。没有任何已知备胎有相提并论或超越这个候选的可能。
市场认知成熟到一定程度,会普遍发现让一个备胎具有替代函数的功能,比从一个成熟的基于函数抽象的接口实现中砍掉功能而定制化(取得丐版)的代价大得太多。不管架构和生产力分工如何演化,这部分变化余地不大,未来也不太可能有变数。
当然,完全无关的古典接口设计方式也会保留,毕竟有不同层次的边缘需求。就像文字被发明之后,画图仍然被作为一种承载信息的手段。但是,健全的用户很容易理解不同手段适用性差距如何。

于是要抽象掉什么,总是会被逐渐成为开发者的用户个别关心。解决方案的演化只会让用户越来越有权决定打算自己动手处理还是让自动化手段替代。
只考虑“宏观”问题的一揽子解决方案的市场占比会越来越小,因为这同样是在排除用户的话语权;同时,这类方案的设计者因为提供粒度有限、表面成本较高(同样主要来自信息不对等)等原因,比其它生产部门经常更容易因为能自动化机制代替而被用户优化掉。
原则上,你需要解答的“宏观”问题,之后会成为只有(没能力干预实际生产活动的)领导关心的奢侈问题。

“你要管理一整套基于函数的语法树,构建 AST 是相对复杂的。而程序化去提取指令,要简单太多。”
你对复杂的认知和主流从业者还不大一样:当年这类选型不用 AST 主要因为实现环境限制(主存不够大),而不是你嫌弃的复杂。
构建 AST 对工业语言基本小菜一碟,反正横竖省不掉 parse ,就输出 IR 格式不一样,分析树前的工作量没多少区别。而且真要计较,不用 AST 避免所谓的复杂性,整体经常适得其反。强行不用 AST 去实现一些变换(同时照顾时间性能),比起能用 AST 的复杂程度可能超过一个数量级。(所谓纯 FP 语言等鼓吹声明式编程风格的实现出来的代码看上去可能好那么点,但代价是输出结果以外各方面不可预测。)
要去除所谓的复杂在一般只会是不成熟的优化。现在业界早就接受先天不足的丐版方案在需求整合面前无能的事实了。
比如说 MSVC 的 cl 因为没有 AST 数十年拖着不实现 2-phase name lookup 最终几乎整个干掉了重写这种,算复杂么。
没 AST 的玩具设计注定无能应对一些常见需求,拖越久成本越高,只配当作一些非专业用户的日常作业。趁早干掉是出路,要么等死。
要找到除极端资源限制理由外适合不用 AST 去处理 DSL 又具有显著现实意义的案例还更难。AST 对应递归语法,不支持的(对应 AST 退化成列表,不用正经的也无关紧要)要么类似原始的汇编这种特别低级,要么像 INI 这样普遍缺乏灵活性。(极端例子,用程序提取 CSV ,听着还不如让用户拿个靠谱的命令行文本编辑器直接处理方便……编辑器还能带脚本支持,命令式函数式的都有,都不用设计方案。)要扭曲成这样,DSL 一般仅是不成熟的历史包袱;若没有现成稳定实现而要花资源维护,这种 DSL 继续值得在设计方案中存在都是个问题。
而所谓“基于函数的语法树”的说法暴露出你不懂如何度量这里的复杂性,也不清楚一般实现。AST 节点不管是不是函数的定义和 /或外部表示,原则上没区别。相反,Lisp 这样几乎什么东西都能当 S-表达式把所有子表达式的组合硬当函数应用(不严格准确,Lisp 函数一般是指过程,然而还有特殊形式;不过更高级的语义模型上可以统一成函数),反而比典型的命令式语言要区分处理各种语句、声明(可能算语句,如 C++;也可能不算,如 C )、表达式之类更多各种乱七八糟的不同 AST 节点简单得多了。而且,要都实现成 AST 解释器,不算 GC 的 Lisp (如 PreScheme )一般也比类 ALGOL 语言还简单点(没简单那么多的理由是类 ALGOL 语言的语义要求可能比较氵,可以成心不支持一等函数)。
函数实现的复杂性来自活动记录管理机制支持,如 GC 和闭包,这些相对 AST 完全是“离线”的,一般要在自由存储区分配;但就算设计降级用丐版函数,只要不丐到组合子的层次、类似硬限制成用 AST 上在线变量替换代替调用的λ演算,或者 shell 外部命令那种整体外包踢皮球给操作系统的奇技淫巧,一样要管理离线资源。这不是实现者来应付,就是当用户用到递归算法时自己重新造轮子(如栈),整体复杂性不会少。强上这种设计,只是便宜了连栈都不会实现却又愣是不想用有健全函数的语言所以干脆把所有表达递归算法的需求都当作不存在的用户(其他用户可能会嫌弃功能太少不像高级语言);基于以上分工演化趋势,这种用户不但不会被视为专业开发者,在非专业用户中也会被边缘化(像只会写自己名字的撑死当半文盲,不会被文化人视作同类一样)。

为何你会倒置复杂性?看上去是因为不熟这些基础。这些知识只是早期看着很高冷,会随开发技能的普及成为常识而不是专业人员的护城河。一如写代码因为专业门槛限制,一开始只是极少数专业研究人员的特权;现在呢?
即便以今天的科普程度看,对命令式语言入门的程序员来说,使用 AST 首先也就是空间换时间的粗浅应用,跟数据结构的入门课程就差不远。缺乏这些基本直觉,说明在试图掌握这类比较基础的知识时训练得很不够或效果怎么都不够好,可能更适合具体应用领域方面的工作。

“你可能没接触过项目遗留屎山……”
如何应对“项目遗留屎山”,或者说如何高效清理垃圾+背锅,是另一个专业领域的问题。一股脑儿扔给负责设计和实现的人员,这是不务正业。
FrankHB
2022-03-04 13:17:10 +08:00
@lanlanye

模式(pattern)这话题不大有趣。背后想要复用设计的需求是真实的,然而基本无解(了解清楚模式是什么不能帮你省多少事)。
模式在此指工程过程中(被假定)可复用的一些实践。真正容易的部分已经被编码到程序中涵盖了,所以这里说的特指无法通过编程手段自动化复用的部分。

模式有个共同点:有名字。这提供“行话”,具体意义经常通过局限性(不能做什么,什么不算是模式的实例)体现。

按规模模式通常分为三类。

最易局部实现的有时叫代码模式,更常叫做惯用法(idiom)。因为和具体语言特性相关这种经常被视为实现手段而非“设计”模式,但其实不确切,因为一些惯用法(如 RAII )很明确地会影响并传染到跨模块的 API 上,而这至少算得上是一种详细设计。叫成“代码模式”也不甚对路。
惯用法的意义在于无法被自动隐含到其它代码中隐式复用,要设计和实现人员长点心记得什么时候适合用,但每个实例又不都一样。

最大的一类是架构模式,涉及到系统整体的宏观设计却又依赖具体项目的经验而具有显著性。讨论架构模式通常较困难,它关注的问题非常大,很多解法和涉众利益相关。除了可用资源,架构模式的选型瓶颈还有涉众的偏好(或者说,眼界),我不认为缺乏具体项目背景的情况下有多少讨论余地。
架构模式的意义体现在它是一类可复用问题解法抽象层次的上限(其实只要你敢想,问题的规模就没有上界)。只要一个问题的规模似乎足够大,遇事不决就有倒腾架构的余地。但还是那句话,什么问题真能算得上架构问题,得看涉众偏好,有些模式也有模糊性(比如 MVC 能算架构模式也能算设计模式)。一般更小规模的模式能确切解决的问题,都不算架构模式问题;所以说叫“上限”。

现实不允许你什么都赖架构,又有些不是记几个惯用法就能解决的。这类问题的套路性一般夹在需求和实现之间的阶段中体现;这种高不成低不就的模式,就被称为所谓的设计模式。然而,它要解决的有多少算设计得打问号。虽然不像惯用法高度依赖具体语言特性,设计模式通常依赖范型(paradigm)更甚于问题域。
例如,OOP 的设计模式和 FP 的设计模式经常不通用,而且对面看来都是冗余的。所谓设计模式跟惯用法没那么大的区别,无非是依赖一个或者几个语言还是依赖一类或者几类语言,中间没有明确界限。

设计模式中经典的 OOP 的部分,跟 OOD 其实没什么关联(虽然实现滥用类后可能强行对应得上)。后者重点是建模,而 OOP 所谓设计只是如何选择声称支持 OOP 的语言提供的特性,跟建模难以对应。
语言设计倒是会提供一些直接的对应。比如说“类”,经常是语言而非模式提供。也有例外,如 C 的标准输入 /输出流用 FILE*其实就是一种基于类的 OOP 实现,但似乎没谁把这种实现方式叫模式,连惯用法都不算。
于是,我不认为类似的所谓模式有多少讨论的意义——既不能帮助明确问题域的边界,又难以涵盖现实存在的(不把模式当回事的用户创造的而不会特意取名)具体语用的惯例,作为单独的复用基础欠缺现实意义。

设计模式还引出一大堆现实问题。
第零,滥用。这个之后讲。
第一,取名不见得有正面作用。
如很多开发者会把单态(monostate)混淆成单例(singleton),以至于不看实际怎么写的单例就判断不出说的是哪个。虽然在此两者经常有足够大的共性使混淆被容忍,但是要想提防混淆,还不如直接说满足什么需求来得清楚。模式提供名字的作用就整体不那么靠谱了。
第二,随意强调即便没被滥用,也容易造成刻板印象下的误导。
一些 OOP 设计模式跟 OOP 也没什么关系。
比如所谓工厂方法(factory method) ,其实只是对一些类 Simula 的 OOP 语言的类的构造器无法多态的变通,而像 C++的解法 make_xxx 根本用的就是参数多态(parametric polymorphism)而不是 OO 特供的包含多态(inclusion polymorphism),一点都不 OO 好吧。
如果说这不是惯用法,反而有些怪。但不同语言用不同多态都能实现,都不见得算 OO ,放到惯用法里是不是又太屈尊了?就有点尬。
第三,可能被语言的演进自然淘汰。
像 C++17 出来 deduction guide 后很多 make_xxx 就下岗了,因为直接声明对象也不用写参数类型,相当于构造函数隐含了参数多态,那何必多此一举 make_xxx ?
(不过因为一些理论上的问题,不管能不能有 C++17 用,我都避免 deduction guide 。)
这倒是从反面体现了一些设计模式的积极作用:揭示日常依赖使用这些模式的语言设计的不足,给语言补充特性指明一个方向。

再说滥用设计模式。
多数工程中这也不算是头等问题,因为味儿(如代码中的废话(boilerplate))明显,一眼看得出来,敢不敢砍掉看执行力。更大的问题是阻碍整体语用的理解,进一步阻碍对语言设计、范型偏好乃至选型策略等一系列问题的正常认知。
OOP 的设计模式啰嗦得太明显所以问题还不普遍( GoF 的书用 C++,然后设计模式长期被 C++用户普遍嫌弃,倒是语言演化一贯后进的 Java 用户更吃这一套才给发扬光大),至少没到 OOP 用户写点程序就想到模式的地步。
这个问题更多体现在某些 FP 语言用户上。

典型的反面教材是 Haskell 的 monad I/O 。这里为实现个 do-notation 的语法糖只是个惯用法,但 API 非得这样依赖 monad 的设计就硬生生地强行搞成了“设计”模式。以至于有用户说 I/O 必 monad ,有点纠正不过来了。
其实 monad 无非是个类似 OOP 的 interface ,顶用的核心原因,甚至都不是 interface 内部能 typecheck 的东西,而是更上层的 monad law 这样的约定(或者说公理(axiom))。然鹅,更基本的其实是 applicative functor……好了,大多数用户到这里就表示投降:不要念经,还是死记硬背 monad 吧。
这里甚至还没说到杯具的表象:用了所谓的 monadic interface ,相当于写的随便到处 CPS(continuation passing style)变换过的程序,于是 Haskell 用户正常情况下是没资格有用其它大多语言的 direct style 表达 I/O 操作的本事的。(其实也有不 monad 的古董,但是用起来体验嘛……)
而杯具的实质是啥?是因为 Haskell 用户很多都是 PFP(purely functional programming)教徒,认为无条件默认拒绝副作用是好的,于是为了有效 wrap 必须具有副作用的 I/O primitive 又想藏起来这些味道不对的东西,就想方设法搞出了 monadic interface 的风格来分离再做成语法糖。
但为何拒绝副作用就好?怕是十个用户九个半都答不对,撑死嘟囔些 referential transparency 就不错了。(然鹅这种解释是错的,PFP 不是支持 RT 的必要条件,OOP 用户都知道还能隐藏内部状态呢……并且,RT 的实际含义比 99%以上的 PFP 语言用户理解的微妙得多,有兴趣补课的搜 Referential transparency, definiteness and unfoldability )。
真正明白点实质的会知道非得这样 monad 才写得顺,在于 Haskell 的 PFP+默认 lazy eval 的设计(不信改掉这些规则试试)。为什么 lazy 是好的?因为 lazy 能 declarative 。为什么 declarative 是好的?……然后怕是又有不少于八成的概率如蜜传如蜜,绕回到 lazy 就是好的这种同义反复上。
也不怪这些用户。实质问题的起源是 Haskell 的一些设计者认为 PFP+默认 lazy 能让 equational theory 会足够强,而默认允许 equational reasoning 能便于程序优化啦 blah blah……至于实际情况呢?反正看着 GHC 一坨 thunk 我就积点口德懒得吐槽了。虽然还是特别想骂骂 seq magic 这种又当又立的。
至于啥叫 equational theory 又如何判断强度呢……算了,超纲太多,自己找 paper 吧,我也弃疗了。。。

讲到这里,就能看出光一个设计模式就能蕴含多少咖喱味的屎了。
使用 OOP 模式的语言也有设计的必要性稀里糊涂这问题,然而好巧不巧至少 Java 非常不擅长缩短屎味废话,并且 OOP 的一些 calculi 都比较拉胯在学术界也没 FP 的 model 那么有存在感,所以 OOP 的设计模式(的屎味溢出)还是挺受控的……大部分工业界写 OOP 代码的都会闻得到点味道,可能忍耐的阈值不太一样。

以上主要围绕设计模式和惯用法的边界展开批判。设计模式和架构模式之间也有暧昧之处,主要例子是 MVC 。
先前提过架构模式通常会关系到不同的涉众。这里建议 MVC 被当作设计模式还是架构模式,一般主要看各个部分组件预期给谁写。如果设计 MVC 框架,打算具体的 M 、V 、C 实例的设计和实现分包给非框架作者的其他用户,这就算架构范畴。否则,如果都自己搞定,可认为这就是关于设计的。这种设计模式倒是真靠近一点实在的设计。
但是我还是比较倾向于把这类模式处理成解决架构问题的套路。如果不打算分包出去,其实不用那么纠结组件边界。事实上,主流客户端 GUI 框架声称用到 MVC 或者变体的,这里都不那么规矩,都普遍进行了偷懒而不是提供符合架构要求的职责的组件(好像就 Swing 比较规整一点)。倒是 MVVM 因为一开始就是 WPF 这种先整出来的,边界还算严格。(但 MVVM 是不是适合这样的需求就另说了。)

所以,整体建议是:惯用法和架构模式看看无妨,当背景知识长见识,被误导风险不大;至于设计模式,不想进 PLT 的坑就别靠太近了,免得积累不够,嘲讽不到位,还被设计模式厨二(虽然也许当事人甚至不知道这叫设计模式)挖坑反埋了。
(我能在一些坑里跳,是因为有积累能对着一堆鸟语写的 spec 实时脑补出一些 formal model 出来,提前看清楚一些问题,比别人多出来的一些时间就能搜到更多黑历史文献了。不建议一般用户复制这种低效的做法。)
DDD 偏架构,但主要给管理者看,多数人也不见得有条件实施。
如果想要从头开始体验抽象,复习 SICP 。先区分清楚抽象掉的能是什么,再考虑建模方法。
层次不一样具体操作不一样,要求也不一样,不要想着上中下三路一口气通吃。
搞 PL 的应该关心的是怎么让编程语言更顶用而不是逼着外行用户不得不放出什么设计模式之类的妖魔鬼怪给语言功能缺陷擦屁股。
工业界负责落实实现的普通用户应该致力达到一个合格水准:看到什么需要设计的东西,就要想着尽量用项目允许的语言趁手写出来(或者保持有底气造反推翻不恰当的选型决策),不看任何所谓的设计模式,也要做到能随手发明出来怎么实现,不在详细设计上阻塞。至于命名,不是想要找别人的废话批判一番的就算了,别添乱了。
精力过剩就找 leader 讨论需求和针对问题域的真正的设计,不要花大把时间纠结在如何给实现方法擦屁股的伪设计上,除非你致力于成为屁股理论专家。

最后跑点题,所谓语法(syntax)也是一坨 formal pattern 。现在 PL 界看来折腾语法过头,特别是搞 type theory 的整个拿语法当方法论整,而对语义注意经常不足,有滥用模式的传统艺能味儿了(即便这 pattern 不需要人去抄)。和一些设计模式的鼓吹者之间有类似共通问题:希望整出一些能摆脱 case by case analysis 而允许机械地复用的东西偷懒,却忽视了和实际需求的联系。
3dwelcome
2022-03-04 15:57:35 +08:00
楼上也太能写了,当然对于函数我个人还是持保留意见。

就和这个世界生物多样性一样,看起来早履虫比人差了很多,但万物进化,皆有存在的理由。

代码也是如此,指令是函数的退化形态,退化就一定落后嘛,也不一定的。适合场景就好,函数是写不完的,代码退化意味你可以把精力省下来,用到别的地方。

以前有句名言,keep it simple stupid ,代码写傻点没什么不好,太复杂是炫技,足够简单才是代码长寿的秘诀。与其写茫茫多的函数,堆代码,我更喜欢在项目里删代码。
lanlanye
2022-03-05 10:50:33 +08:00
@v2exblog 分层问题简单来说就是使用分层架构,我接触到的分层架构主要有两种,一种规定每层只能访问自己下面一层,另一种规定每层都可以访问下方所有层,我觉得两种都行。
CRUD 就是跟着数据模型走,设计好数据库,本质上就是开发一个界面好看的数据库管理工具。
如果你的业务很复杂,无法用这种简单的方式实现,则可以参考 DDD 中的方式对领域进行分析。

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/835769

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX