没用过 Stata,不过看主题内容,感觉楼主是混淆了“文件”这个操作系统 /文件系统的概念和编程语言自身的概念。
大多数常见编程语言都是围绕“值”“表达式”“函数”“类”“接口”这些结构折腾。这些结构构成了一个编程语言的核心部分。编程语言的实现,以及该编程语言使用的库,可能会引入新的概念。比如 C++ STL 库里引入了臭名昭著的“迭代器”概念,而一些做数据分析的库,比如 pandas,就引入了“Dataframe”和“Series”的概念,这个和具体库的 domain 有关系。
写一个程序的过程,可以理解为:
1. 首先定义一个问题
2. 然后再将问题递归地拆分为更小的问题
3. 最后使用编程语言或库中的相关结构和概念实现这些问题的解决方案
这么一个过程,或者说通过分治解决子问题来解决更大的问题,这些子问题及其对应的解决方案就可以被称为“模块”(注意这部分的“模块”和 Python 中的“模块”结构没有直接关系,后面会解释)。能够清晰合理地划分模块的边界,并且将同一模块的东西攒在一块,叫模块化( modularity )。
楼主说的“两个功能的完全不同”“逻辑清楚”就是在实践模块化。
模块化主要是定义及划分模块,这些工作做完之后,最后还要有一个把模块重新拼在一起,由解决小问题到解决大问题的过程,这个过程称为组合( composition )。组合的过程简单自然称为组合性( composability )。
当然具体做这个的过程中,由于计算机没法直接理解你的问题和子问题,所以到了干苦力那一步的时候,还是要落到编程语言和库自身的结构中,具体来说就是通过这些结构的组合来“编程”,也就是刚才的步骤 3 。就是说这些结构也是可以组合的,在大多数编程语言中,上述提到的“值”“表达式”“函数”“类”“接口”这些结构都能相对容易地组合。设计良好的编程语言和库,最重要的一点之一就是其结构或模块容易组合。
“编程”的过程,最后的产物叫“程序”。我们说它是“编程语言中结构的组合”,是因为计算机只理解编程语言。我们把这些结构附加上人类能够理解的意义后就成为我们刚才说的“模块”。楼主产出了 A 和 B 两个文件,这两个文件在计算机看来就是 Python 的类、函数和语句这些结构,而在人看来则是用于解决一个问题的两个子问题的两个模块。
可见这里存在一个从计算机编程语言的结构,到设计层面的模块的映射。这个映射其实是相当自由的,也就是一个公式可以是一个模块(比如它解决了计算某个期望值的问题),一个正则表达式可以是一个模块(比如它解决了判断是否是合法的 Email 地址的问题),一个类可以是一个模块(比如它解决了访问数据库中某种实体的问题),一个函数可以是一个模块(比如它解决了数据加密的问题),甚至一个函数中的单独一段代码也可以成为一个模块(比如在加密数据时,判断是否应该在源数据尾填充零数据以满足对齐要求)。
很多编程语言,包括 Python,单独做了一个叫做“模块”的结构。这种“模块”结构,解决的其实是“我有多个相关的结构,设计上属于一个模块,该怎么放在一起”的问题,也就是一般用于表示一些更加基础的结构的集合。比如 Python 中的模块可以包含变量、函数和类。
刚才其实有个小问题,我 composition 的单位,究竟是“模块”( module ),还是“组件”( component )?从上一段的例子可以看出,似乎人们(至少 Python )更喜欢把具有“集合”性质的东西定义为 module,这个我叫狭义的 module 。而对 component,似乎有一种观点是高层设计上的划分叫 component,执行层面代码实现的产物叫 module 。之所以说是小问题是因为我认为这个这俩基本是同义词,至少当我们讨论 modularity 和 composability 时没有必要区分,不管 module 还是 component,都是指模块化设计产生的东西,它们也应该可以组合,不一定是某种集合才能叫 module 。比如一个函数本身不是集合,但是每个函数确实有明确定义的接口,会完成特定的工作,通过函数来组织程序,相比于直接一口气写一大段确实有更好的 modularity 。(更需要区分的其实是 modularity 和 composability:前者是把问题划分为模块,后者是把模块重新组合成问题)
这就是为啥刚才说我们讨论的“模块”和 Python 的“模块”不是一个东西,前者是一个特定的产品中为了解决某个专门的问题的结构。类似的限定于单独产品的词还有很多,比如 React 框架里面 component 表示的是 React 实现的 UI 模块,Linux 内核中 Kernel Module 表示的是一种动态扩展内核功能的工具,微软的 COM 中的 component 则试图在不同机器和不同编程语言之间建立一个共有的结构,这些“模块”和“组件”的含义都是在专门的 domain 下的。我们谈的“模块”是通用的。
---
之所以要费这么多字辩经,是因为很明显这个有搞混的可能 ... 不过大部分人都应该知道区分同一个词在特定语境和通用语境的差别,所以如果不考虑我刚才那套“广义模块”和“狭义模块”的歪理的话,并不容易搞混。但之所以我还要强行输出“广义模块”和“狭义模块”这套歪理,当然是因为我拿了拜登的经费(还有别的可能么?),有意要塞私货——我并不喜欢(看上去)非常流行的“狭义模块”定义,我也并不喜欢很多常用编程语言中特定的“模块”结构,这些问题导致了楼主的困惑(也导致了很多其他问题),而最好的解决方式就是把“模块”的默认定义切换到“广义模块”。
关于“狭义模块”定义本身的问题,刚才已经有说过了。到了编程语言层面,对“狭义模块”的实现,又是各自有各自的问题。首先是该不该有这个单独的结构,变量、函数、类这几种常见的结构,都可以用于实现“狭义模块”,但是很多编程语言为了“狭义模块”单独做了一个结构,很有种多此一举的感觉,但是这么做的不在少数,是因为语言设计者的剃刀都钝了么?这个得具体来分析(注意下面的例子中的“模块”都指狭义模块):
Python 中的一个“模块”就是一个文件(有使用文件内代码创建模块的方法,不过很少用),几个模块在文件系统中组合在一块叫一个“包”( package ),这个意义其实就是把模块再组合一遍。
Go 中一个文件夹算一个“模块”,同一个文件夹中的名字默认是互相可见的。当然 Go 里面相关结构的名字和其他不太一样,Go 貌似把一个文件夹叫 package,几个 package 组合到一块再叫 module 。
Swift 中一个“模块”指的也是源文件的集合,一般以“framework”的形式出现,和 Go 类似,这个集合内所有的符号是互相可见的,单个文件并不构成一个“模块”。
Bash 中不存在明确的“模块”结构,最接近的就是文件 ... 文件本来就是 Bash 的核心结构
C 本身不存在“狭义模块”层级的结构,但是有个预处理器,预处理的就是文件,而所谓“头文件”基本代替了模块接口的作用。C++ 有 class 和 namespace,这俩都可以用来表示“模块”,但是好像 C++ 程序员普遍不喜欢定义太多 namespace,class 倒是很常见,class 配合 template 也有不错的组合性。但是 C++ 还是依赖于头文件,最近才有新的 module 功能。注意标准文档的说法是程序放在“source files”里面,source files 预处理之后变成“translation unit”,同时还说明这个“source file”的名字只是 conceptual 的,不一定真在“files”里面。
Rust 中的模块有点像 C++ namespace 和 Python/Go 模块的混合体——你可以使用文件或文件夹来定义模块,也可以直接在文件内定义新的模块。一般项目里貌似前者用的较多。
Java 的核心结构是类,一个文件只允许定义一个类或接口,文件夹结构决定如何引用这个类和接口。但是这其实不是一定的,因为这个规则只是实现上 javac 编译器和 JVM 内置 ClassLoader 的默认行为(可以和 C 进行比较的是,在标准层面限定的其实是 compilation unit 和 top level declaration,具体行为是实现决定的,文件系统只是其中一个情况),JVM 实际可用的最内层接口是单个 class 的字节码而不是文件,理论上你可以用你自己的 ClassLoader 甚至自定义的 .class 文件格式规避掉默认规则。另外在我们讨论的这些语言中,Java 属于最依赖于 IDE 的那一类,这也减轻了默认规则的影响。
JavaScript 的设计背景和 C 完全不同,但是同样导向了没有“模块”结构的结果。然而 JavaScript 的函数和对象有强大的抽象和组合能力,直接就可以拿来做“模块”。于是后来就出现了一些比较通用的模块实现,现代 JavaScript 最后有了一套标准的模块,和 Java 一样,这个模块也可以自定义 loader 。最后实现上很多都是把“模块”和“文件”或“文件夹”对应起来的。
这么一圈下来,很显然的就是很多语言及语言实现,跨 domain 把编程语言中的结构,和操作系统中的文件系统嫁接到一块了。文件系统有意思的地方主要是,在现代 OS 中,文件系统是唯一指定的持久化存储抽象。用户或者程序想要存储数据,不管采用什么方式,最后基本都要落到文件系统上。
这本来没什么大问题,能直接钦定一个抽象,并且还一直很好用,只能说明这个抽象是个成功的抽象(至于所谓“UNIX 哲学”把这玩意扩大化到了整个 OS,搞得操作系统就是文件,文件就是操作系统,文件调度内存外设安全,东西南北中,文件是领导一切的这种事情另说)。但是“模块”一定要和文件系统关联起来么?再去看“模块”的定义,狭义上指编程语言结构的集合,广义上指一切可组合的编程语言结构,这和“文件”或“文件夹”并没有必然的联系。之所以实践中存在广泛的关联,可能有多方面的原因,但是主要的原因可能不难推测:如果说一切持久存储,包括程序语言结构(即“程序”)的存储都要落在文件系统上的话,就必然要设计利用文件系统存储程序的方式,很自然的,一个文件或文件夹很有可能被设计为编程语言结构的集合,而我们说的狭义模块就是编程语言结构的集合,这俩看起来是差不多的,干脆就画等号了。
这看起来很合理,但是我想再强调“文件”是文件系统的结构,“模块”是程序设计的概念或者编程语言的结构,这俩本身并没有直接的关系。所以正经的标准,比如 C/C++,Java,JavaScript 里面,都不会钦定文件系统作为模块的必要条件,而只是将其作为可能的实现之一,也就是说文件系统是个实现细节。
Python 、Go 等语言把模块和文件系统强行关联会出现一些问题,比如想要把各种语言结构放到一块(即创建一个狭义模块),必须涉及文件系统的操作,总的来说就是程序结构和文件结构高度绑定。当然有一些人可能会觉得用文件来组织模块这样更“清晰”,不过就我个人来讲,文件固然有其合理性,但是我读写程序的时候,希望的是能针对编程语言结构进行操作,而我刚才说了,文件不属于编程语言结构,绑定到一起之后在编程过程中需要在编程语言和操作系统两个 domain 中反复横跳,这就是楼主会提出这个问题的根本原因。
引入广义模块定义,并且把“文件”的概念排除掉之后就更清楚了。楼主说“with open....”“不是那个味道”,就是因为你这个时候需要跨 domain 操作文件(其实这是“文件”的正确打开方式,文件是拿来读写的不是拿来 import 的),而“是味道”的做法应该是直接操作编程语言结构,在 Python 里就是操作函数和表达式,以及它们在运行时产生的值,即:
A.py:
def phase_a(src):
...
return result
B.py:
def phase_b(src):
...
return result
main.py:
import A
import B
src_data = read_src()
m = phase_a(src_data)
n = phase_b(m)
output(n)
但是因为我们不提倡把模块和文件绑定,既然写两个文件 /狭义模块是模块化,写两个函数也是模块化,你把 phase_a 和 phase_b 两个函数(模块)全都放在
main.py 里面也无所谓。
有句话讲得好“一切都是比较而言”。C++,Rust,JavaScript 等语言,允许用户更灵活地定义狭义模块,狭义模块和文件系统不存在直接关联。Java 虽然默认实现有关联,但是因为现在默认写 Java 都要用 IDE,理论上 IDE 可以帮你解决。
另外楼主问的“中间量”,我敢保证楼主程序中的“中间量”不止 m 一个。因为当你计算 a = 1 + 2 + 4 时,1 + 2 的值 3 就是一个“中间量”,之后会继续产生另一个中间量 2 + 4 也就是 6 作为 a 的值。因为 a 本身不是程序的结果,你后面还需要用 a 进行其他的运算或者输出,所以 a 也是个中间量。上面例子的 src_data,m 和 n 都是中间量。可见这些中间量,编程语言实现已经自动帮你处理了,都很“是味道”。楼主说“A 文件就类似于一个面向过程的流水账,一步步计算出结果的这种”,这么多步,每一步都会产生“中间量”。但是楼主唯独对跨文件的“中间量”不清楚,就是因为“文件”这一个 foreign 的概念干扰了对编程语言自身概念的理解。
---
最后都说了这么多了,可以继续来看几个非常奇怪的例子:
虽然之前所讨论一些编程语言会把其“模块”结构称为“module system”,但是除 JavaScript 外,我个人不觉得其他任何一个有叫“system”的资格。Module System 的典范其实是 ML 语言,ML 中的 module 有点像 C++ 里的 template,是后加上去的,也把 ML 分成了 Core language 和 Module language 两个部分。ML 模块的最大特点是它有很强的组合性,也就是你可以像函数一样调用模块(叫做 functor ),当然和 template 一样,这种调用只能是静态的。和函数不同的是,模块允许值和类型互相组合,而函数只允许值的组合。又因为模块是唯一能把值和类型组合到一起的结构,所以 ML 的模块既有接近于函数等一等公民结构的组合性,又不存在功能的冗余。ML 有两个主要方言,Standard ML 和 OCaml,Standard ML 并没有把模块和文件系统关联起来,编译器一般只是把所有源文件合并成一个大文件编译,当然是合并要以某种拓扑序进行。OCaml 则会给每个源文件默认创建一个模块,OCaml 加入了 first-class modules,让 module 也可以在运行时动态组合,进一步增强了 module 的表达力。(需要注意 Standard ML 有标准,标准认为“we shall tacitly regard all programs as interactive”,OCaml 没有形式上的标准)
另一个是传说中的罗马正统奥斯曼(划掉)“面向对象正统”Smalltalk 。今天人们对 Smalltalk 仅有的记忆似乎主要是来自它的罗马 ... 面向对象正统宣称,但是 Smalltalk 的创新远不止于此。Smalltalk 经常被忽略的一点是,它将程序和“环境”连接在了一起,因为 Smalltalk 是 Alan Kay 和 Xerox PARC 在让计算机和编程变得更友好的过程中的尝试,所以它不使用“文件”的抽象,你的计算机或者操作系统(至少在 UI 层面),以及编程环境对你来说都是同一个“Smalltalk 环境”,这个环境中你写程序不是先创建一个“文件”,然后再写代码,而是直接创建一个类或方法。读程序也不是打开“文件”看,因为用的不是“文件浏览器”,而是一个“System Browser”( Smalltalk 环境里面有个“File Browser”,不过你编程的时候是不会去碰它的),你在这里面直接点类或者方法看。因为代码直接可以在“环境”中运行,和程序及数据交互等于和环境交互,它也很适合 Live Coding 。总的来说 Smalltalk 更多地做到了“针对编程语言结构进行操作”以及“文件系统作为实现细节存在”。
Smalltalk 中持久化也和文件没有直接关系,刚才说你的电脑就是 Smalltalk 环境,Smalltalk 是把整个环境存储成一个“Image”,关机(或者进程退出之后)再打开会加载 Image,类还是那些类,方法还是那些方法,对象还是那些对象。
这俩东西都有几十年历史了,今天倒也不是完全没有继承者,可以简单找到几个猴版:
JavaScript 等动态性较强的语言,可以直接用动态特性实现 ML 模块的组合性。
现代 IDE 都有个 Class View,可以看当前文件,或当前项目下按照程序语言结构( namespace 、类、函数、变量等)组织的程序结构。不过貌似至少 VS 里面的 Class View 只能看,不能直接创建类(因为 VS 还是没脱离文件抽象)。VS 和 Xcode 都可以创建某种“文件夹”,这个文件夹仅仅是一个逻辑的集合,仅存在于 IDE 项目文件中,不对应文件系统中的文件夹。
RDBMS 的存储过程不直接对应文件系统,而是存储在数据库里面。关系数据库本身又是一个持久存储的抽象,实际上 Smalltalk 的 Image 就是个数据库。(“文件”的问题也就在于文件系统可以被当成数据库,但是这个数据库又不够强)
Java 在标准层面主要关心类和包这种抽象的概念,而在实现层面又直接让类、包与文件系统绑定,但是单个“文件”不是“结构的集合”,而只能有一个结构,并且用 IDE 工具进一步抽象。也可以算是奥斯曼,即 Smalltalk 的继承者之一了。
另外,Smalltalk 这个 Image 机制和现在主流编程语言实现的区别,其实就是 Smalltalk 给 VM 进程状态做了持久化而已,楼上有人说这个在现代的 VM 中并不难实现。这里又涉及到另一个操作系统概念“进程”,进程有自己的内存空间,一般就代表了一个进程在某一时刻的状态(嘛,也可以叫“中间量”),楼主说的“变量框全局地储存变量”,以及回复有人说的全局作用域,都属于进程的状态。但是如果程序自己不做持久化,这个状态在进程退出之后就没了。存储分临时存储和持久存储,文件主要解决的是持久存储的问题(当然也可以做临时存储),临时存储主要是进程内部的事情。所以楼主如果没有持久存储的需求是不需要读写文件的。Smalltalk 环境把文件和进程都给你抽象掉了,你可以说它和传统操作系统做了比较全面的脱钩。
如果真的让“模块”,或者更一般地,程序的组织形式完全和“文件”脱钩,也会出现其他的问题,比如你就只能用对应语言钦定的环境和编辑器,Vim 和 Emacs 基本废了,我虽然只有三十岁,可以却有五十年的 Vim 配置经验啊!(我觉得 Emacs 还能救一救)版本管理也是个问题,理想上也是按照编程语言结构来进行管理,而不是按照文件来管理,这样就又会有只能使用语言专用工具的问题。Smalltalk 社区应该有自己的办法,我不是用户不太清楚。不过这些问题会出现的前提是现有工具本身就是按照主流编程语言和文件系统之间关系的假设做的,还是个历史包袱问题。