资源分组与URL设计

在开始之前,我们先观察一下我们已经非常熟悉的Linux的文件系统的路径是如何设计的。比如

/etc/rc.d/rc.local

Linux中从根目录开始,一层一层的检索想要获取的文件。根目录的名字是 “/”,他是固定不变的,在根目录后面是根目录中的文件名称,这里的名称是rc.d/,然后以此类推,直到最终要访问的文件。

现在我需要问你一个问题,文件的名字是保存在哪里的?很多人一直以为文件的名字是保存在文件中的,但是实际上,文件仅仅保存了文件的内容,跟文件相关的其他属性都没有保存在文件中。其实文件的名字是保存在它所在的目录中的,也就是说目录其实也是文件,只是比较特殊,目录的内容就是目录中包含的文件的信息。

实际上我们计算机到硬盘中去取文件的时候根本不需要知道文件的名字或者路径,而是使用文件在硬盘上保存的位置,这个位置信息也保存在目录中。也就是说,我们在取文件的时候,首先到固定的位置获取根目录的内容,根目录中保存着根目录下的文件的名字和保存位置的映射关系,Linux通过文件名字确定第二级目录的保存位置,然后再取第二级目录的位置,直到成功获取最终文件。

通常,一个文件只会保存在一个目录下,但是,实际上,一个文件是可以同时保存在多个目录下的,这样我们可以通过多个路径对同一个文件进行操作。

因为按照路径索引需要通过文件在目录中的名字获取它的保存位置,所以同一个目录下不能有两个同名的文件,这样我们才能在一个目录下唯一的确认一个文件。

但是按照目录索引还有一个很大的缺点,那就是因为不能直接获取文件的保存位置,想要检索某个文件就变得比较困难,如果你不知道你要查找的文件的完整路径,那么计算机只能通过依次在每个目录中检查你要检索的文件是否存在来帮助你了。而搜索文件的这个过程是十分漫长的。

但是,如果你记住了每个文件的保存位置,你可以直接通过这个保存位置来获取文件,这样可以跳过所有的目录层级,直接读取文件,这比使用路径访问文件还要快,但是这样会有更大的麻烦,没有人会记住他要是用的所有文件的保存位置。

但是,我们可以两种方式都使用,我们可以先使用层级目录的方式获得文件的保存位置,如果我们需要频繁的访问这个文件,我们可以把文件的位置记录下来,下次需要访问的时候直接使用这个保存位置操作文件。

理解了上面的内容对设计URL有哪些帮助呢?

  1. 文件的路径是文件名称的层级连接
  2. 文件的保存位置在硬盘上是唯一的

借助上面两条概念,我们在设计URL的时候也可以采取同样的措施,资源的名称不再是保存在文件的记录中,而是保存在父级资源组中,这样一个URL就变成了

/根资源组名称/次级资源组名称/…./资源名称

不过这样会有另外一个问题,文件系统中只有两种文件,目录和普通文件,而URL中,每一层资源组实际上都是一种资源,而不单单是为了组成索引。所以我们会需要看到URL就能知道每一层资源组具体对应着什么,比如

/某某公司/某某部门/某某办公室/某某人

我们需要在名字上直接说明资源组的类型,这样显然是浪费口舌,还有一种办法,就是把资源组先创建普通的组再添加到上级资源组中。比如

/公司/某某/部门/某某/办公室/某某/人/某某

这样,我们认为所有的公司在同一资源组中,而所有的公司在的组在根目录下。这样我们就可以轻松的了解资源组的性质,而且不需要为所有的资源组的名字加上资源组的类型。

不过还有一个问题,就是同一级目录中不能有两个重名的资源,如果我们在根目录下放了一个资源,他的名字就叫公司,这样和公司这个资源组会冲突。所以我们在起名字的时候就会更容易重名。不过我相信不会有人傻到犯这样的错误。

先将某种类型的资源放在这种类型的资源组成的组里再将这个组添加到父级资源组中,这样目录的层级就更加深了,就需要更多次的跳转了。如果目录的层级是固定的,比如部门一定是在公司下面的,那么我们可以通过他们的关系简化这个这种跳转。在层级是固定的情况下,如果你不在乎浏览者看到URL时会困惑的话,你也可以直接使用之前的方法,省略资源名称上的类型说明,直接使用

/某某/某某/某某/某某

这种索引方式。

但是最好的办法还是直接使用资源的保存位置来获取资源,在设计URL时,这个保存位置就是资源的ID,不过不同的资源往往是保存在不同的表中的,我们需要先说明资源所在的表,通常表名和资源类型是有对应关系的,也就是说

/人/ID

可以直接说明我们要找的是谁。如果你不是特别在意资源的层级关系,这样检索还好,但是如果你同时希望原来的层级检索方式还可以用,这样的表达方式就会有歧义。

我们是要找一个资源类型为人,保存位置为ID的资源,还是在根目录下找一个类型为人,名字为ID的资源组?

我们可以通过在根目录下创建两个不同名字的目录来区别

/Indirect/公司/某某/部门/某某/办公室/某某/人/某某

/Direct/人/ID

很显然上面两种路径表示的意思是不一样的。

如果你觉得Indirect和Direct会让你的访问者困惑,你也可以通过URL参数来指定索引

/公司/某某/部门/某某/办公室/某某/人/某某

/人?id=ID

后者的目录在根目录下只有一层,很明显不是按照层级查找的。

当然你也可以混合着两种模式

/公司/不确定/部门/某某/办公室/某某/人/某某?公司id=ID

或者

/公司/某某/部门/某某/办公室/某某/人?人id=ID

甚至

/人/某某?办公室id=办公室ID

当然,这样的索引即难写又难读,不推荐混合使用。

既然我们可以使用id来所有,那么使用名字搜索其实也不是不可以

/公司/某某/部门/某某/办公室/某某/人?name=Name

这样的表示方式和

/公司/某某/部门/某某/办公室/某某/人/某某

检索的结果一样,反而描述更加复杂了,似乎没有什么必要,但是我们可以这样检索

/公司/某某/人?name=Name

很明显,这样得到的应该是某某公司里所有名字为Name的人。原来的表达方式是没法完成这样的操作的。除了要实现这样的接口,你还必须把某某公司所有的人组成一个资源组并添加到某某公司下面,当然你的接口自动按照层级依次检索也没有什么问题,只是那样要执行的操作就太多了。

权限管理系统设计的一点理解

权限管理要回答的终极问题是某人对某资源是否有某权限。

围绕这个问题,最简单的方法就是直接记录某人对某资源是否有某权限,如果查询到有,那么答案就是是,如果没有那么答案就是否。

但是如果权限分的非常细致,可能的权限很多,那么某人对某资源的拥有的每一个权限都要记录一下,这样就要记录的行就太多了,要解决这个问题,可以对一些固定一起出现的权限进行合并,比如,要对某个资源进行改写,起码要有读取的权限,那么读和写可以合并为一个读写权限,这时在用户拥有写权限的时候就不需要额外记录一个读权限了。

通常我们会把对资源的权限抽象为增删改查,但是我们依然不能阻止有些情况下我们需要将权限更加细分,比如某人对某资源有读权限,同时有排序的权限,但是没有改写的权限,或者某人只能改写某资源的某一部分等等,这样细分下去,就会产生非常多的权限,如果在按照上面的方式对权限进行合并,排列组合就会产生更多的权限。这样就会使权限描述变得更加复杂,编程的时候要面对各种各样的权限。要解决这个问题,我们可以添加权限组的概念,首先梳理清楚某人对某资源存在的所有的基本权限。然后根据应用场景,要执行某一请求,则这个人必须拥有某些权限,然后将这些权限合并为权限组,编程的时候不需要管到底有哪些基本权限,只需要考虑在哪种应用场景下某人需要对某资源有哪个权限组。

当然,我们不希望用户可以拥有的权限那么复杂,我们先假设只需要记录几个权限,比如增删改查。但是我们依然需要为某个用户对应的每个资源进行记录,如果我们想要复制某人对应的多个资源的所有权限给另外一个人。比如某个人拥有编辑某些大量的文章的权限,现在给他安排了一个合作者,需要拷贝前者拥有的所有可以编辑的文章的权限给后者,我们就需要拷贝所有的记录给后者,这样需要进行大量的拷贝。如果这种情况时常发生,那么仅仅记录某个人对某个资源拥有某种权限的方法就会显得很繁琐。这种情况下,我们可以创建一个资源组,将某个人拥有的某些有共同特征的资源添加到一个组中,不再记录这个人对某个资源的权限,而是记录这个人对应这个组的权限。他对这个组有什么权限,就对组中的成员拥有什么权限。当我们给他安排一个合作者的时候,只需要添加后者对这个组拥有某些权限就可以了。

但是如果我们要复制权限的资源并不是很多,复制一次权限只需要添加几条记录的时候,我们不需要添加资源组。在这种情况下还会发生另外一种困难,如果很多人对某几个资源拥有共同的权限,比如我们希望100个人组成一个小组,他们共同维护几个资源,并且这个小组的成员很不稳定,经常会发生人员变动,那么每次发生人员变动都要修改变动的人对应这几个资源的权限记录。 或者这些人对应这几个资源的权限经常发生变化,每次发生变化都要修改每个人对应资源的权限记录。为了减少每次改动记录的条数,我们可以创建一个用户组,把这些人添加到一个用户组中,不记录每个人对应每个资源的权限,而是记录这个用户组对应这几个资源的权限。每次发生人员变动的时候只需要将发生变化的人添加到用户组或者移除用户组。

极端一些,如果有一大群人管理一大堆资源,而且他们对应每个资源的权限都是一样的,那么我们可以既使用资源组又使用用户组,当人员发生变化的时候就把人员添加或者移出用户组,当资源发生变化的时候就将资源添加或者移出资源组,当权限发生变化的时候我们直接修改用户组对应的资源组的权限就可以了。甚至我们还可以同时使用权限组,可以设计很多细分的权限,然后将这些权限分到各种各样的权限组,用户组和资源组之间的关系靠权限组关联。而某个人对应某个资源的权限使用某个用户对应某个资源的某个权限进行特殊处理。相信你看到这里一定觉得这个系统太复杂了,不容易维护,而且每次鉴权都要考虑很多种情况,某个人对应某个资源的某个权限,可以来自自身,可能来自用户组,可能来自资源组,可能来自权限组。

当然同时使用用户组和资源组的时候还有一种异常简单的情况,只需要判断某类人是否拥有某类资源的某个权限。比如所有的学生都拥有进入教室和在教室学习的权利,而不关心他们具体是谁,在哪个位置听课。如果一个系统中的所有权利赋予都是这种情况,我们就不需要单独记录某个人对应某个资源拥有某个权限了,而是直接将用户添加到用户组,资源添加到资源组中,然后直接关联用户组和资源组就可以了。

当然还有另外一种例外,那就是可以有用户组,也可以没有用户组,可以拥有资源组,也可以没有资源组,权利联系的双方具体是什么没有关系,只要他们符合某种条件就可以了,casbin就是这样的情况,只需要判断左右值是否符合条件,如果两边都符合条件,那么这个人对他请求的这个资源就拥有记录中的权限。然后通过符合条件的索引获得的资源一定是这个人拥有权限的资源。符合条件可能是因为他是某个用户组的成员,也可能是因为他就是条件中的唯一的一个人,而资源可能是因为它属于某个资源组,也可能是因为他就是条件中唯一的资源。

如果你要为某一类人创建对某一文件的某一权限,那么你就需要让代表这一类人的字符串有唯一的共同性。如果你要给某人某一类资源的某一权限,那么你就需要代表这一类资源的字符串有唯一的共同性。当然你也可以任性的记录某一个人对应某一个资源有某一权限,只要这个人的索引和这个资源的索引是唯一的就可以了,但是这样需要记录的权限条数可能会很多。

这样的设计特别适合用户和资源有层级关系的系统,比如某个公司使用某个文件系统。我们通过某部分的某某名字描述一个人,通过文件路径描述文件。那么部门就是用户组,目录就是资源组,这样用户的索引和文件的索引都是唯一的,而且表明了他们之间的关系。

当然没有层级关系的情况依然可以适用这套权限管理系统,只不过限制不在是在索引中,而是需要手动添加,比如,检索某个名字为name的文件,然后再url参数中添加作者为user,当对作者为user名字为name的文件拥有读权限的用户检索的时候,casbin就会放行,系统检索出所有名字为name,作者为user的文件返回,这样用户就获得了他想要的作者为user名字为name的文件,也因为他拥有对应的权限,他才顺利拿到了文件。

关于使用用户-资源组-权限组方式管理权限的分析

比如 A用户拥有1资源修改权限

权限管理的终极问题是 某个人是否有某个资源的某个权限
按照之前的理论 如果资源往往不是单独出现的,那么我们可以使用资源组的概念进行包装。

上面的描述就变成了

1资源属于 一资源组


A用户拥有一资源组修改1资源的权限 

这样的描述可以解除上面的描述

而A用户对一资源组不可能仅仅拥有几个权限,他可以拥有修改1资源的权限,也可能拥有修改2资源的权限,那么我们可以把这些权限合并到一个权限组中。

上面的描述就变成了


1资源属于一资源组
修改1资源的权限属于管理员权限组
A用户拥有一资源组管理员权限组

上面3条描述同样可以接触第一个答案。

然后某个人对某个资源是否有某个权限的问题就变成了

某资源是否属于某资源组

某权限是否属于某资源组

某人是否拥有某资源组的某权限组

这样虽然看起来更加繁琐了,但是在权限转让和资源变动的时候不需要像第一种描述那样,每个人对应每个资源的每个权限都要变动,只需要修改某人拥有的权限组或者某资源是否属于某资源组就可以了,谁变动修改谁,而不是一个环节变动其他环节都要跟着变。

这种用户-资源组-权限组的描述是实际上生活中很常见的。比如,小明是发财公司的网络管理员。小明就是用户,发财公司是资源组,网络管理员是权限组。

这个时候有人可能会觉得网络管理员是用户组而不是权限组,这种想法实际上是不对的。用户组的描述应该是 小明属于发财公司的网络管理员组。这两种描述的内涵是有区别的。

粗略整理网络安全问题

一. 什么是中间人攻击:

要理解中间人攻击,首先要理解数据在网络中是如何传递的。我的电脑要访问某个网页,我需要发出访问请求,这个请求从我的电脑发出后不是直接到达目标服务器,而是需要委托很多设备帮忙传递,而且我们也很难明确确认我的消息是谁帮忙传递的。但是好处是,我发出的请求通常都能够顺利到达服务器,虽然可能每次走的路不尽相同。服务器收到请求后会制作一份回复信息,这个信息同样经过很多设备发送到我的电脑上。在我和服务器中间帮忙传递信息的这些人就是中间人。

如果我的信息没有进行加密,那么任何中间人都有机会查看和篡改我的信息,如果发生了这样的恶意行为,就是我们所说的中间人攻击。那么中间人为什么要发起攻击,这样能获得什么了好处呢?比如,我的请求包含了重要的隐私信息,那么恶意攻击者可以查看并拷贝一份数据,然后对我进行勒索。或者,比如我的信息是请求服务器转账给某人(银行的系统会有复杂的流程防止中间人攻击,我们假设的可能只是某些不安全的游戏网站中的虚拟货币等等),那么这个中间人就可以把收益者改成他的账号,并将服务器返回的信息改成成功转账给我要转账的人。这样,我和服务器就都蒙在鼓里。

当然,防止中间人攻击其实也是很简单的,直接使用加密算法对信息进行加密,中间人看不懂,也就没法篡改了。

二. 什么是重放攻击:

我们在上一段中提到通过信息加密可以简单的防止中间人攻击。但是如果中间人是一个更聪明的人,虽然我们对传递的信息进行了加密,但是他还是有办法进行攻击。

首先中间人可以诱骗你给他转账,比如,他要卖给你一个游戏道具,让你转给他对应的钱,你觉的价格很合适,于是你给他赚了钱,就在这时,中间人截获了你发送的转账请求,虽然他看不懂你给服务器发送的请求是什么内容,但是他完全可以将同样的信息再发送给服务器一份,这时,服务器就以为你又发送了一份转账请求,于是服务器就又给他赚了一笔同样的帐。只要你的账号上还有余额,中间人就可以一次次的将你的钱转到他的账目上。这种攻击方式就叫做重放攻击。

那么如何防范重放攻击呢?基本的思路就是让中间人重新发送的内容失效,比如每次向服务器发送的请求中携带一个请求计数,每次发送请求的时候都将这个计数增加,只要服务器收到了之前出现过的转账请求,就认为这次请求无效,而中间人只能发送你发送过的信息,并不能自己编写请求,这样就无法冒充你给服务器发转账请求了。

三. 什么是跨站脚本攻击

跨站脚本攻击是利用网页的漏洞,通过一定手段向网页中注入恶意代码并使其执行,这种攻击手段比较多见,并且因为直接控制了客户端,危险系数很高。

跨站攻击的原理并不复杂,但是可能发生的情况却很多,最简单的跨站脚本攻击方式是向URL参数中注入js脚本,当用户使用这个url访问页面的时候,js脚本就会执行。

比如黑客诱骗你给他转账一定数额,这时他告诉你要购买你的游戏道具,并且给出的价格比较合理也很诱人。然后他发给你一个链接,然后告诉你从这个链接就可以转账。你打开网页之后发现确实是你在用的那个游戏的转账网页,而且他已经贴心的帮你把数额填好了,你只需要点击确认就可以完成转账,但是实际上这个URL中已经被他添加了恶意脚本,只要你点击确认,恶意脚本就会把你转账的数额修改,那么你的钱就会被黑客骗走。

当然URL注入脚本最起码你还要通过这个URL打开网页才能中招,如果黑客将恶意脚本直接注入到网站中,只要你打开这个网站,恶意脚本就会运行。比如将恶意脚本注入到评论中,可能你在交易市场跟别人讨价还价的时候你的账号就已经被攻陷了。

那么如何防范跨站脚本攻击呢?最直接的办法就是把注入的入口堵死,发现注入的参数可能是恶意脚本就直接阻止这次访问,可以使用正则检查用户输入的文本。或者限制用户可以使用的字符。当然,上文也提到了,跨站脚本攻击的种类很多,魔高一尺、道高一丈,攻击和反攻击的套路也是会不断升级的。

四. 什么是sql注入

上文提到的跨站脚本攻击的目的就是让恶意脚本在客户端执行,但是sql注入却可以让恶意脚本在服务器端执行,结果会更加可怕。

sql注入的原理和跨站脚本攻击的原理一样,只是注入的恶意脚本会让数据库管理程序执行,防范的方法也一样,就是检查过滤输入的参数。当然如果你使用的是成熟的ORM,通常ORM本身就具有防sql注入的功能。

五. 什么是跨站请求伪造

跨站请求伪造是利用验证信息会保存一段时间的特点,在用户访问B网站的时候,冒用用户的验证信息向A网站发起恶意请求。比如A网站是一个银行网站,而你同时也在访问B网站,B网站的恶意程序会冒用你的验证信息向A网站发送请求,实现转账的恶意行为。

通常我们使用的浏览器都支持防范跨站请求伪造,浏览器在发送请求的时候会检查发起请求的页面和目标地址是否同源,如果不同源就认为存在风险拒绝访问。但是有的时候我们确实需要跨站请求,比如C网页不是一个恶意网站,只是正常调用A网站的接口。这种情况下,C网站通常已经通过了A网站的验证,A网站认为C网站是一个安全的网站,就会在他的允许列表中添加C网站的地址。浏览器得到回复后,获悉A网站认可C网站,就会允许这次访问。

未完待续…

消灭指针!

指针这个词对于从C开始入门的程序员一定是非常熟悉的,回忆起刚开始接触指针的时候真的是痛苦万分,绞尽脑汁的去理解。

实际上指针的概念非常简单,就是一个记录了一条内存地址的变量,对于理解计算机内存的人来说还是很容易接受的。但是对于不知道内存里面是什么样子以及变量是怎么保存在内存里的人来说,想从根本上理解指针其实是很困难的。所以很多高级语言到最后都会舍弃这个概念,但是对于习惯使用C/C++的程序员来说,指针又是必不可少的一个特性。

本文的目的是探讨如何消灭指针这个概念,而不是真的要让指针这个东西不复存在。我希望有一种语言向使用者隐瞒指针这个概念,但是却能继续使用指针的特性。下文中我们会一点一点构建这门语言的特点,来实现隐瞒指针的目的。

首先我们来分析一下,为什么我们需要指针。要明白这个原因,就要先搞明白变量和内存空间的关系。

在C中没有声明过的变量是不能使用的,声明变量这个动作是人的理解,实际上计算机的动作是分配一块待使用的内存空间。随后我们使用这个变量进行操作的过程中,计算机实际上就是在使用这块内存空间在工作。所以变量实际上就是一块内存空间,而变量的值就是内存空间里保存的信息。不同的变量类型对应的是不同大小的内存空间,所以必须声明的时候就要确认变量类型 ,正是这个原因导致C是一种强类型语言。另外我们在刚刚学习C的时候老师曾经提过,C中刚刚声明的变量没有初始化的时候内容是随机值,实际上这样解释是错误的,C中没有初始化的值中的内容并不是混乱不堪的,而是之前程序释放的内存空间中原来保存的信息,只不过我们拿到的值不一定正好是一个完整的变量,可能是一个变量的一部分,也有可能是几个变量不同部分组合在一起,这就像从刚刚打完一局的扑克牌堆里随便抽出来几张一样,可能正好是一套,也有可能是乱七八糟的几张。当然这些是题外话,但是可以帮助你理解变量到底是怎么回事。

获得了内存空间之后,我们计算机就要使用这块内存空间进行工作了,但是我们在使用变量的时候,计算机怎么知道什么时候用哪一块呢?我们在声明变量的时候不光声明了变量的类型,还给每个不同的变量取了不同的名字,这个名字就是我们说的变量名,变量名本质上是记录在文本中的字符串。实际计算机在工作的时候并不依靠变量名,只要你理解了计算机是工作在二进制环境中就不难理解为什么计算机不会使用变量名去判断内存空间。计算机在工作的时候实际使用的是内存地址去区别不同的内存空间,我们编写的程序在编译的过程中,编译器会结合变量的类型把变量名这个字符串转换成一个地址区间。上文中我们提到变量类型对应的是内存空间的大小,而变量名对应的是内存空间的起始地址,起始地址和空间大小就完整的描述了一块确定的内存空间。我们可以想象内存就是一个大仓库,如果里面的每个箱子我们都根据里面的物品取一个名字,这样我们在编写工作清单的时候就很方便,我们可以直接写我要什么东西,这个名字就相当于变量名,但是这样的话工人去工作就很麻烦了,工人要查看每个东西的名字才能确定您要的是哪个箱子,最好的办法就是把这些箱子摆放在贴有顺序标签的货架上,工人只需要知道箱子所在位置的顺序号和箱子的数量就可以快速的定位你要的箱子,这个顺序号就是内存地址,箱子的数量对应不同变量类型的大小,所以我们在取物品的时候中间需要一个翻译,把我们编写的清单中的箱子的名字转换成起始地址和箱子的数量。这个翻译就是编译器,当然编译器做的工作不只是把变量名转换成空间地址那么简单。

理解了内存空间和空间地址,下面我们就来解释一下为什么我们需要指针。

我们在刚开始学习C的时候通常都会犯一个错误,那就是将一个变量传给某个函数后,期待这个值会因为这个函数的工作发生变化。比如下面这个例子:

int a = 1;
int conv(int a) {
  a = 2;
  return a;
}
int main() {
  conv(a);
  printf("%d", a);
}

在执行了conv之后,a的值依然是1,这是因为C的参数默认使用值传递的方式。就像我们定义变量一样,在执行到conv的时候,计算机同样会为这个函数分配一块内存空间,这个空间里会包含这个函数中定义的变量,也就是说conv函数中的局部变量a的内存空间是因为执行conv新创建的,随后计算机会把全局变量a对应的内存空间中的内容拷贝到局部变量a对应的内存空间。后面的操作都是对局部变量a的操作。所以全局变量a不会发生变化。大多数情况下我们执行某个函数的时候都会分配一块新的内存空间,然后把需要的值拷贝的这个新的内存空间中,执行完成后再通过返回值将这个内存中的值拷贝出来,随后函数执行完成,这块内存空间就会被收回。这个拷贝的过程我们称为值传递。通常这个过程不会造成什么麻烦,大多数时候我们也都是通过拷贝传递参数的。但是当参数的数据类型需要很大的空间的时候,拷贝就会花费大量的时间,所以有些情况下,我们希望执行函数的时候直接对传入的变量进行操作,也就是跟我们期望的一样全局变量a会直接发生变化。

C语言传递参数的过程中无论如何都会执行这个拷贝的过程的,如果我们直接将变量本身作为参数传递是一定会拷贝这个变量的。所以C语言的设计者发明了指针这个概念,不再将需要操作的变量本身作为参数,而是将变量的内存空间地址作为参数,通过指针告诉新执行的函数我们要操作的变量在哪里,这样新的函数就会直接去操作这个变量。借用我们上面内存是个大仓库的例子,仓库里有很多人在干活,有的时候一个工人要把他负责的箱子交给别人去处理,但是如果箱子很多,他可能要搬好几趟才能交接完成,这个时候他可以不亲自把箱子传递给对方,而是直接告诉对方这些箱子所在位置的顺序号,这样对方就可以直接取取用需要的箱子,而不是接过所有的箱子。这个通过传递内存空间地址实现参数传递的方式就是引用传递,而传递地址的载体就是指针,指针本质上还是一个变量,本质上还是通过拷贝实现传递的。

除了明确声明指针并通过指针传参,我们在使用C语言的时候同样会发生通过传递内存空间实现传参的过程。比如我们在将一个数组作为参数传递给被调函数,如果被调函数修改了这个数组的成员,原来的数组是会直接发生变化的。数组本质上其实也是一个指针,他表明了第一个成员的内存空间和数组的长度,所以在将数组作为参数传递给被调函数的时候,实际上拷贝的内容是这个地址空间,这也是一种引用传递,只是我们在学习C语言的时候默认将数组作为一个独立的变量看待,认为数组的值传递比较特别。

从上文中我们可以看到,提出指针这个概念最大的好处是,可以通过使用变量本身或者指针作为实参方便的控制进行值传递还是引用传递。但是实际上,在传参的过程中无论如何都要发生值传递的,即使是引用传递,我们还是要把指针的内容拷贝的被调函数新分配的空间中。

与C/C++相比,python中就没有指针这个概念,但是在python中调用函数时,想要直接让函数修改作为参数的变量却十分困难,因为在python中,我们不能直接修改“变量的内容”。没有学习过python或者对python理解不深的程序员可能不太明白这句话的意思。让我们先来看看python中的变量具体是怎么回事。

在C中我们声明变量,计算机就会分配一个内存空间,这个变量和这个内存空间是始终对应的,但是在python中不是这样的。我们可以把python中的变量理解为C中的指针,而这个指针所指向的内存空间才是真正保存变量内容的地方。

比如我们在python中执行 a = 1,计算机就会在内存中分配一个类似指针的空间给a,然后分配一个空间保存1,并让a对应的指针内容为1的地址,也就是让a指向1。

如果我们继续执行b = a,计算机并不会重新为内容分配空间,而是分配一个类似指针的空间表示b,并将b指向a指向的内存空间,也就是1所在的内存空间。

如果我们继续执行b = 2,计算机会重新分配一个空间用来保存2这个值,并将b指向2这个值所在的内存空间。

这样a继续指向1,b却指向了2。在python中变量的内容怎么保存不是我们编程控制的,python有自己的机制取为变量的值分配内存空间,我们修改的是变量对应的那个类似指针的内存空间。所以上文我才说我们不能直接修改“变量的内容 ”。如果我们继续执行 b = 3,如你所料,b会指向一个新的保存着3的内存空间。那么原来保存2的内存空间会怎么样呢?

那不是我们需要关心的事情,如果一个内存空间没有任何指针指向它,python的内存管理机制会在合适的时机清理这块内存空间。

理解了上面的机制之后我们在来看看在python中调用函数的时候是如何传参的。

a = 1
def conv(a):
  a = 2
conv(a)

如果我们执行上面的程序,会发生什么呢?跟C/C++一样,全局变量a同样不会发生变化,发生变化的仅仅是conv函数中的局部变量a,在函数执行完成之后局部变量的a同样会被清理。这段程序的过程是这样的,我们执行 a = 1,计算机分配一个内存空间对应a,然后分配一个内存空间保存1,并将a对应的内容指向1,接着我们执行conv(a)的时候,计算机为conv函数分配空间,重新声明的局部变量a保存在conv所分配的空间中,因为我们传递全局变量a给conv这个函数,所以局部变量a的值并不是保存在conv所分配的空间中,而是全局变量a所指向的刚刚分配的保存着1这个值的空间。当执行到conv中的a=2时,发生变化的是局部变量a,计算机重新分配一块内存空间保存2,然后让局部变量a指向这个保存着2的内存空间。

如果你觉的上面的程序不好理解,你可以把全局变量a叫做a,把局部变量a叫做b,看起来就是这样的。

a = 1
def conv(b):
  b = 2
conv(a)

甚至,如果我们执行下面的程序,结果还是一样。

a =1
def conv():
  a = 2
conv()

因为python默认函数中的变量都是局部变量,也就是说conv函数中的局部变量a依然是因为执行conv函数才重新分配的,而python额外做的工作让局部变量a指向了和它同名的全局变量a所指向的内存空间。如果你非要直接修改全局变量a指向2,那么你可以使用global关键字声明函数中直接使用全局变量。

a = 1
def conv():
  global a
  a = a
conv()

但是我们不可能为了直接修改传入的参数而把这些参数全都定义为全局变量,绝大多数情况下我们都是使用返回值为这些变量重新赋值。

a = 1
def conv(a):
  a = 2
  return a
a = conv(a)

除此之外我们还可以这样执行

a = [1]
def conv(a):
  a[0] = 2
conv(a)

这样a[0]的值就变成了2,那么为什么a作为一个列表传入就可以被被调函数修改了呢?实际上,conv函数并没有直接修改a指向的值,conv函数中的a依然是一个局部变量。别忘了python和C/C++一样,列表并不是直接保存内容,而是指向内容的内存空间。

比如我们执行 a = [1, 2, 3],内存中的情形实际上是下面这样。

当我们执行 b = a 时,跟普通的变量一样,内存中会重新分配一个内存空间表示b,并将b指向a指向的内存空间。

当我们 执行b[0] = 2的时候修改的实际上是上图中的[0]的指向,如下图所示。

所以我们在读取a[0]的时候发现a[0]也发生了变化,在上面的例子中,conv中的局部变量a就和上图中的b一样,都是重新分配的。虽然这样可以实现直接修改原来参数的目的,但是却仅仅局限于变量是列表的成员的时候。

我们可以看出来,python的这种机制实际上很好的隐藏了指针这个概念,却和C/C++中的引用传递一样,传递参数的时候拷贝的内容仅仅是一个内存空间的地址而已,并不会真正拷贝变量的内容。python的设计可以说是十分巧妙的。但是却因此遇到了一个麻烦,当变量的值发生变化的时候,我们总是需要将函数的返回值赋值给原来的变量才能让原来的变量发生变化。这没有C/C++中使用指针那样方便的把一个值交给函数去处理。相信你也看出来了,这种巧妙的机制带来的另外一个麻烦就是对初学者十分不友好,特别是对那些从C/C++转过来的程序员来说。

在C中单一的变量我们通过直接拷贝变量内容的方式将变量传递给一个函数,复杂的变量我们通过拷贝表示变量地址空间的内容(指针的内容)将变量传递给一个函数,而在python中无论什么变量,我们都通过拷贝变量的地址空间将变量传递给一个函数,而且在python中我们不直接修改变量的值的内容,而是通过将变量指向另外一个值来改变变量的内容。

借鉴C和python的特点,我们可以假设我们要创造的这门语言所有的变量都通过拷贝内存空间地址的方式在函数见传递,但是我们不会像python中那样通过修改变量的指向来改变内容,而是实实在在的修改变量指向的内存空间中的内容。这种方式就像我们在C中为每个变量声明一个指向它的指针,并在函数中始终通过传递指针来实现参数传递。

这样会导致的一个结果是,无论我们的变量在函数中传递多少次,最终的修改都会导致原本的变量发生变化。回到一开始我们提到的C语言初学者容易将变量传递给被调函数并希望被调函数直接修改变量的情境下,这种期待就变得理所当然了。

我们重新看第一个例子,我将它转换成下面这个样子,这是我假象的我们想要创造的语言看起来的样子。

new a = 1
new conv = (a) {
  a = 2
}
conv(a)
print(a)

在我的设想中,所有需要为内容分配空间的操作都通过new这个关键字实现,无论是变量还是函数,所以我们执行new a = 1的时候,计算机会分配一个内容空间表示a,然后分配一个内存空间保存1,并将a指向1。当我们执行conv(a)的时候,并没有将1这个内容进行拷贝,而是在conv这个函数的内存空间中分配了一个内存空间表示局部变量a,然后将全局变量a中保存的空间地址拷贝给局部变量a,在conv中执行a = 2的时候通过局部变量a中保存的地址直接改写原来保存1的内存空间的内容为2,当函数执行完成之后再看全局变量a的值就变成了2。这和初学编程的人的直觉相吻合。

为了方便解释,我同样将局部变量a改写成b,就像下面这样:

new a = 1
new conv = (b) {
  b = 2
}
conv(a)
print(a)

这样写的执行结果和上面的结果是一样的。如果我们这样理解 a 是保存1的地址空间的名字,那么b就是保存1的地址空间的另外一个名字,我们实际做的是给这个地址空间又取了一个名字,在全局环境中,我们认为保存1的内存空间的名字叫做a,而在conv函数内,我们认为这个内存空间的名字叫做b。实际上a和b对应的是同一个变量。

再回到内存是一个大仓库的场景中,甲向仓库中存入了一个箱子,为了方便管理,他给这个箱子起了一个名字叫做a,并把这个名字记录在自己的日志中。箱子存入仓库的时候箱子放在了某个确定的位置,比如100这个位置。如果a要取用箱子,他需要告诉仓库的工作人员箱子的位置是100。现在甲需要委托乙对箱子进行一些特殊的操作,他告诉乙的是箱子的位置100,而乙为了方便管理,他给箱子重新起了一个名字叫做b,而b对应的也是100这个位置的箱子,当然乙也同样可以给这个箱子取一个名字叫做a。这个比喻中甲乙就像是两个函数,而a和b是两个函数中的两个变量名,他们表示的都是同一个变量,也就是那个箱子代表的变量。他们最终做出的操作都是对同一个箱子进行的。

即使在同一个函数中,我们同样可以给某个变量取两个名字

new a = 1
b = a
b = 2
print(a)

执行上面的程序,a的值被改变成了2,也许你注意到了在声明变量名b的时候我没有使用new关键字。正如我前面所说的,为内容分配空间使用关键字new表示,而在执行b = a的时候我们并没有为内容分配新的空间,只是分配了一个用来保存空间地址的内存空间,然后把a中保存的空间地址拷贝到b中。

但是如果你使用常量直接为b赋值而不用new关键字,比如直接执行:

b = 1

这样程序是会报错的,因为计算机没有为内容分配内存空间,1这个值也就没有地方保存。

所以在没有没有使用new关键字的时候,我们只能使用一个已经存在的变量为新的变量名赋值 就像上面那样。

new a = 1
b = a

如果我们希望创建一个新的变量,并通过一个已经存在的变量为他赋值,但是不希望在修改这个新的变量时改变原来的变量,我们可以这样执行。

new a = 1
new b = a
b = 2
print(a, b)

上面的程序执行的结果将是 1 2,因为我们在声明b的时候使用了new关键字,计算机为内容分配了新的内存空间,并将a所指的内存空间中的内容拷贝到了b所指的内存空间中。

相信你已经猜出来了,如果我们传递一个变量给一个函数,但是不希望函数值会改变原来的值,我们应该这样写。

new a = 1
new conv = (new b) {
  b = 2
}
conv(a)
print(a)

在上面的程序中,我们在声明函数conv的时候明确表示b是一个新的变量,计算机会为他的内容分配新的空间,并且在执行conv(a)的时候,拷贝的不是a中保存的内存地址,而是a所指的内存地址中的内存。

除了上面这种标准的方式,我们还有另外两种啰嗦的方式实现同样的结果。

方法1:

new a = 1
new b = a
new conv = (b) {
  b = 2
}
conv(b)

方法2:

new a = 1
new conv = (a) {
 new b = a
 b = 2
}
conv(a)

看完了上面这两种方式,你是否能分析出来计算机到底拷贝了那些信息?

这两中方式虽然和标准的写法结果一样,但是使用的情景却不相同。方法1是我们在向一个会改变传入值的函数传递参数的时候不希望a被改变时所采用的。方法2是希望函数可以在不改变原来值的情况下同时拥改变原来的值的能力时所采用的。当然通常使用默认的方式就可以了。

在上面我们假设的语法中,我们不需要明确表明指针的概念,而是通过new关键字区分是否创建一个新的变量。学习者在学习的时候也不需要理解内存空间到底时怎么回事,只需要知道使用new b = a的时候创建的是一个全新的变量b,它不会影响任何原来存在的变量,使用 b = a的时候只是为原本就存在的变量起一个新的名字b,对b的操作和对a的操作会得到相同的结果。

设计这种语法我还有另外一个目的,就是鼓励直接传递参数给函数让函数处理这个变量,我希望被调函数更多的时候直接修改传入的变量,而不是执行完成之后通过返回值给原来的的变量赋值。当然我们还是需要通过使用返回值给变量赋值来获得函数执行结果,但是也仅仅推荐返回值是被调函数中全新声明的变量的时候和返回程序是否执行成功等状态。

除了上面两种方法,我们还可以通过添加copy关键字实现一种更加简单的写法。

new a = 1
new conv = (copy a) {
  b = 2
}
conv(b)

这种写法和上文中的方法1语义是相同的,copy关键字的意思是复制a给一个匿名变量并传递这个匿名变量。

比如:

new a = 1
b = copy a

上面的代码的意思是,创建一个新的变量a并赋值为1,创建一个匿名变量,然后拷贝a的内容复制给这个匿名变量,然后给这个匿名变量起一个名字叫做b。所以b就不是a的别名了,而是一个匿名变量的别名。

同时这种语法还会遇到其他几个问题:

问题1:

容易给一个已经存在的别名赋值,并希望这个别名成为另外一个变量的别名。

比如:

new a = 1
b = a
执行了大量语句后
new c = 2
b = c
b = 3

从语法上来看,我们声明了一个新的变量a并赋值1,然后给a起了一个新的名字b,然后我们声明了一个新的变量c并赋值2,然后将c中的值2赋值给b,这样a的值也就变成了2,然后将3赋值给b,这样a的值就变成了3。

但是我们不能排除作者在编写这段程序的时候写到b = c的时候忘记了他曾经给a起过一个相同的名字b,而是以为b是一个从来没有用过的名字。他希望b成为c的别名,并希望在修改b的时候c能发生变化。而语法检查是不会发现作者的目的的,也就无法给作者提出建议。

想要解决这个问题我有下面几个解决方案:

1.不再使用缺省的方式为某个变量创建别名,比如使用 name关键字,执行name b = a 这种方式为a取别名,而 b = c 这个语句明确的表示用c的值为b赋值。那么这个作者就会这样编写程序。

new a = 1
name b = a
new c = 2
name b = c
c = 3

2.保留原来的语法,但是推荐用户在使用的时候使用特殊的别名格式,比如使用 b_a = a 来为a取别名,这样就在看到别名的时候就明确的知道这个别名是哪个变量的别名。这样这个作者就会这样编写程序。

new a = 1
b_a = a
new c= 2
b_c = c
b_c = 3

我更倾向于第二种结果,毕竟这种情况并不是经常发生,没有必要为了某些粗心的程序员添加特意添加一个关键字,我更希望语法更加简洁。当然这样无论如何我们也没有办法为将一个已经存在的别名给另外一个变量做别名用了。我们每次起的别名都是一个新的名字。

如果你非要实现使用一个已经存在的名字给一个另外一个变量做别名的话,我们也只好添加一个关键字,使用name b = c来明确的表示使用b作为c的别名,不论这个别名之前是否用过,甚至我们可以使用name a = c来把a作为c的别名。

但是我感觉这样实际上并没有带来多少好处,在python中如果我们忘记一个变量已经声明过了,同样会发生不想出现的覆写,这一点都没有影响python变得流行。

问题2:

没法用语法检查传入的值是否会像作者预期的那样被修改或者不被修改。虽然我们在语法中使用new conv = (a, new b){}这种方式控制传入的变量是否会被修改,但是依然不能排除,某些粗心的程序员在编写程序的时候忽略函数声明中的关键字,导致原来不想被改变的变量发生变化或者想要变化的量没有结果。发生这种情况语法检查不会起到任何帮助。

对于这个问题,我希望通过增加程序语义化实现,让编译器在编译的过程中可以结合语义化的注释或者变量名为作者提出建议,比如我们可以直接在传入变量的时候添加某些关键字表明自己的用意。

如执行conv函数的时候这样写

conv(a, const b)

语法检查在读取到const关键字的时候明确的知道作者不希望b被被调函数改变,如果这个糊涂程序员编写程序的时候这样写

conv(const a, b)

那么语法检查就会给出警告,a变量可能被被调函数修改,请留意!

看到这里,有些人可能会提出,C/C++中我们明确的知道传入参数是被调函数的输入,返回值是执行的输出。但是使用本文假设的语法,我们传入的参数可能被改变,也有可能不被改变,这样函数的结果不够明确。但是实际上使用C/C++的程序员也会通过传指针给被调函数希望通过指针直接修改给出结果,返回值仅仅用于错误码,而且这种情况是非常多见的。另外在javascript程序中,也会经常把输入值和输出值写到同一个object中一起传给被调函数。这样还有一个好处,就是在异步执行的时候,我们不需要等待返回值,甚至可以直接返回执行的中间过程,而不是等程序完全结束了只能拿到一个最终结果。

好了,本文的探讨到这里就结束了,从本文中相信你也可以感受到,我推荐直接将变量传递给被调函数,让被调函数通过修改传入的变量返回结果,返回值仅仅用于返回函数执行状态。使用new关键字控制是否创建新的变量,使用new关键字声明变量时会为变量的内容创建新的内存空间。

在写完本文之后,我又有了下面这些考虑,但是我还没有想到到底应该如何处理。

1.我们可以使用copy实现同样的结果。

比如:

a = 1
b = copy a
c = a

上面的程序中,a是一个新声明的变量,声明后将1拷贝到a指向的内存空间;声明b的时候将a指向的内存空间中的内容拷贝到b指向的内存空间;声明c的时候直接将c指向a指向的内存空间。这种声明方式在声明变量的时候必须对变量进行初始化。

如果使用前文中的new关键字实现,那么下面的程序是否有错误呢?

new a = 1
new b
b = a

请问b是一个新的变量还是a的别名呢?当然我们也可以像python那样,声明变量的时候强制要求初始化,那么new b这个语句就不合法了。

2.另外如果我们给变量一个不同类型的值,那么势必就需要申请新的内存空间,但是这个变量的别名无法指向这个新的内存空间,比如下面的程序。

new a = 1
b = a
a = "Hello"
print(a)
print(b)

很明显,我们在给a赋值Hello的时候,计算机申请了新的内存空间,并将a指向了这个新的内存空间,但是b还是指向保存1的内存空间,没有发生任何变化。

另外比如下面的程序

new a =1
new conv = (b) {
  b = "Hello"
}
conv(a)

虽然在函数中我们修改了b的内容,但是a的内容不会发生任何变化。

当然,我们可以通过语法检查警告程序员这样可能会出问题,比如当语法检查程序发现一个拥有别名的变量或者在形参列表中声明的变量的类型发生变化的时候发出警告。

或者拒绝隐式的重新分配内存空间,比如下面的程序。

new a = 1
a = "Hello"

这个程序在编译的时候会提示类型错误,我们必须明确的表示重新分配内存空间。方式如下

new a = 1
new a = "Hello"

这样我们会为a重新分配内存空间用来保存”Hello”,当然这样的语法到底算是强语言类型还是弱语言类型呢?当一个变量的类型发生变化的时候,实际上它也就变成了另外一个变量。

看完了上面部分,下面需要讨论一下数组了,因为没有了指针,那么指针数组这个东西就不存在了,但是我们有需要这样的功能,那么需要怎么区分指针数组和数据数组呢?在C中,数组占用的是一串连续的空间,两个相邻的成员之间的距离就是每个成员的大小空间,这样的好处是效率高。比如我们声明 int a[3] = {1, 2, 3},当我们取其中一个成员操作的时候,比如 a[2] = 1,其实a[2]这个操作就是 a + 2,因为C中数组本身就是个指针,它指向的是数组的第一个成员,也就是 a其实就是一个指向a[0]的指针,按照 a[0]等效于 a+0,我们可以看到 a 和 a[0]确实是一回事。所以C在取数组成员的时候只需要拿到数组的地址,然后加上指定的偏执就直接得到了成员的地址。

但是在python中,数组全都是通过指针实现的,要得到数组成员至少需要通过指针跳转两次。

当我们取a[2]的时候其实首先要拿到a所在的地址,然后读取a所在地址中的内容,然后将得到内容加上偏执得到a[2]所在的地址,然后读取这个地址中的内容,再根据得到的内容才能读取出来3这个数字,同样的操作在C中直接就可以去读取a[2]的内容,但是在python中却需要读取好几次。显然效率明显要低的。

不过作为更高级的语言,python带来的更多特征可以为开发带来很多便利。我们首先参照python的设计,看看这样的数组在我们设计的语法中会是什么样子的。

未完待续…

插曲:关于for循环的思考

for循环中的变量有两种声明周期,比如我们用C写下

for (int 1 = 0; i <10; i++) {
    printf("i:%d\n", i);
    int j = i;
    printf("i:%d\n",j);
}

其中i的声明周期是整个for循环,在任何一次循环中都能读取到同一个i,而j的声明周期仅仅是j所在的一次循环,每次循环中的j都不是同一个j。而在C中我们将程序写成下面的样子同样可以执行。

for
(
    int i = 0;
    i < 10;
    i++
)
{
    printf("i:%d\n", i);
    int j = i;
    printf("i:%d\n",j);
}

我们都知道()中的三条语句分别是初始化动作,判断条件和结束后动作。考虑到i和j的声明周期不同,我们可以看到i和j的生命周期不同正式因为在两种不同的代码块中,而初始化条件仅仅只有一句有的时候确实有点寒酸。所以我觉得最好还是专门设计一个代码块用来初始化循环。形式就像上面的循环的变种写法一样,但是可以在代码块中运行任意数量的程序。

比如可以这样编写

for (
    int i = 0;
    i = i+1;
{
    printf("i:%d\n", i);
    int j = i;
    printf("i:%d\n",j); 
}
    if (i < 10) break;
    i ++;
)

上面的写法只是初步的设想,就像你看起来一样,它太过繁琐,而且一点也不直观,但是至少我们可以在整个循环的声明周期中进行任意数量的操作。

另外很多新的语言中 for if while 都不再需要() 但是很多语言在在for if while中还是要使用;的。所以何不使用;的形式呢?为了语义性更强,我觉的可以这样写。

for int i = 0 [i < 10] i++ { 
    printf("i:%d\n", i)
    int j = i
    printf("i:%d\n",j)
}

如果你需要在循环初始化或每次循环结束时执行更多的操作,可以使用多行语句实现,或者,我们依然可以像C中那样用;来结束一条语句

for
    int i = 0
    i = i+1
   [i < 10]
    i ++
{
    printf("i:%d\n", i)
    int j = i
    printf("i:%d\n",j)
}

也就是说我们用[]包含一个语句,将语句的结果作为判断条件,所以if语句同样可以这样写。

if int i = 1 [i < 0]  {
    printf("i:%d\n", i)
    int j = i
    printf("i:%d\n",j)
}

这样我们就可以从容的在if执行判断之前对条件进行处理,并且不需要担心这个if代码块执行完之后会残留任何变量。虽然看起来我们甚至可以在执行判断之前执行其他的 if for while这样的语句,但是我并不推荐把if初始化写的太过复杂,如果在条件执行之前有大量的语句需要运行,最好还是通过调用函数实现。

联系上面的循环体中的两个作用域,我们再来看看现代语言中的for in 语法,有没有觉得不对劲?for in 语法中通常都会声明新的变量用来遍历数组,但是这个变量的作用域是整个循环还是每次循环?如果是每个循环,那岂不是每次循环都重复声明?如果是整个循环,那岂不是每次循环拿到的数据都没有发生变化。实际上,但从格式上讲,这并不符合我们上文提到的作用域的规则,这种写法只是完整的for;;的一种简写形式。所以严格讲,这种写法是不符合逻辑的。而使用数组对象的foreach的方式就显得合理的多了,我们只需要传递处理函数给foreach函数就可以了,具体怎么循环的是foreach内部处理的我们不需要关心。所以如果严格按照上文讲的作用域规则,for in这种语法是没法实现的。不论把in关键字写在中括号外部还是内部都没法循环。

如果我们使用类似foreach的方法实现遍历,那一定要向foreach传递函数吗?或者说,函数一定要像模像样的定义吗?当然不用,很多语言都支持匿名函数,直接在参数中定义匿名函数就可以了,但是如果没有必要传递参数给这个匿名函数,是不是都不用像模像样的定义匿名函数了?我的答案是:是的。我们来看看我们是怎么定义一个函数的。

function <函数名>(<参数>, ....) {
     定义函数内变量
     执行函数内语句
}

函数名是指向这个函数的指针,形参是函数内部定义的变量,实参是要拷贝到函数内部的外部变量,如果我们的函数既不需要函数名,也不需要传参,就只剩下function {}这样的结构了,实际上就是个代码块嘛,再进一步,连function关键字也省略了,岂不是直接可以通过{}定义一个函数吗?我觉得是这样的,当然有很多语言使用{}来定义数组或者字典,那么就需要考虑一下怎么避免字面冲突了。

关于类型和变量的思考:

在静态语言中要定义一个变量,首先这个变量的类型应该存在,而数据类型实际上就是描述变量的内存占用,计算机通过数据类型的值这个变量应该如何记录在内存中从而可以保存和读取变量。使用C/C++或者Golang这些语言的时候,我们会频繁的定义数据类型,因为经常会有某些变量在结构上有差异。相比之下,动态语言在很多时候不需要定义数据类型,比如python中我们可以任意的给某个对象添加或删除属性,实际上这就是在动态的修改这个变量的结构,只是在动态语言中数据类型不再和存储结构强制关联。所以我会觉得,在动态语言中定义数据类型是多余的,既然可以在程序运行过程中自由的组件变量,何必要先定义类型再实例化呢?事实上,我们再python中也可以完全不使用class关键字进行面向对象编程。

既然不再将数据类型和存储结构绑定,何不直接使用变量创建新的变量?将一个变量在内存中的记录拷贝到另一块内存区域不就是创建了一个新的变量吗?被复制的那个变量就是模板,新的变量就是副本。于是我们通过模板的概念取待了原来的数据类型的定义。而且任何一个变量都可以成为一个模板。这样可以有更高的灵活性。比如,我们可以通过赋值指定模板属性的默认值。我们知道在C中新创建的变量的值是不确定的,因为C不会对变量进行初始化,而在Golang中变量会在创建时被初始化为0值,而这个0值是Golang的作者设定的,如果我们想要某个变量创建时就初始化为某个特别的值,就需要为这个类型创建构造函数并使用构造函数创建变量。而使用模板代替数据类型后,模板属性的值就是创建变量的初始值,因为变量的创建是通过拷贝实现的,并且我们可以在任何时候改变这个模板的初始值。

既然变量可以作为模板,那常量是不是也可以作为模板呢?比如我们设置 new a = 1,实际上就是拷贝常量1的内容到新创建的a的内存空间中,那么强制类型转换是不是可以认为是在拷贝后再修改变量的值?这样 a(2) 的意思就是拷贝a的内容,并将拷贝后的值修改为2的值。虽然这看起来有点奇怪,但是逻辑上还是讲的通的,既然变量可以这样执行,那么常量应该也可以 比如 1(2) 也可以解释为拷贝1的值并将内容修改为2的值,虽然会引起歧义,不过那个可以通过修改关键字解决。

面向对象编程相比面向过程编程最大的区别是实现了数据和逻辑的绑定,不过实际上在C中我们也可以使用结构体实现类似面向对象语言的特征。而面向对象中的对象不过是一个数据和逻辑绑定的内存区块罢了。上文中我们使用副本代替数据类型实现变量的创建,实际上是有问题的,我们忽视了值不是一开始就在内存中,所有内存中的值都是通过程序创建的,所以他们根本上都是来自程序中的副本。程序中的值是程序编写时就确定的,是运行时不会改变的。所以从根本上讲我们还是需要数据类型那样的预先定义好不能随意更改的副本。

很多人在编程的时候并没有意识到变量名和函数名是不同的,变量名描述的是一块程序运行后的内存区块,而函数名描述的是一个代码块,只是在程序运行的时候为了程序流畅运行,这个代码块也是保存在内存中的。相比之下,类和实例也是这样的区别,实例时通过类创建的,而类是程序设计者定义的一个模型。

既然类和函数都是程序设计者定义的模型,那么我们为什么要区别对待呢?

就像javascript中根本就没有类这个东西,对象都是用函数创建的,函数运行的时候函数内部的上下文就是函数本身,实际上javascript中的对象就是一个保存的上下文罢了。如果我们把函数内部的上下文在函数结束的时候返回赋值给一个变量,同样可以得到一个对象。

而在有些语言中,可以用{}创建一个独立的运行上下文,比如在golang中。

func main() {
  var a = 1
  {
    var b = a
  }
}

main函数中的花括号里面的变量不会影响到花括号外面,其实花括号可以理解为一个直接在main函数中运行了没有参数值也没有返回值的匿名函数。

如果我们定义一个类似javascript中的new的关键字将花括号中的这段上下文保存下来,那不就是创建了一个对象吗?

new a = copy {
  new a = 1
  new b = 2
   print(a, b)
}()

没错,又是copy这个关键字,之前我们拿它来拷贝变量,现在我们拿它来创建对象,是copy有两层语义吗?不是的,其实copy的意思是拷贝一个上下文,也就是说,我们在执行 a = copy 1的时候copy也隐含着运行1中程序的语义,只是1中没有程序,也就没有运行。而在这里,copy运行了一个代码块,并将运行完成之后的内存区块拷贝到a新创建的内存区块中。也就创建了一个有a和b两个成员的对象。但是这两个对象都是a内部的成员,从外部无法访问,a就是一个完全封闭的对象,在程序中没有任何意义,所以我们需要另外创建一个关键字将a中的成员暴露出来。

这个时候你可能突然意识到,花括号包围的不正是一个构造函数吗?是的,他就是一个构造函数,如果你想给这个构造函数添加参数值那么你可以使用圆括号定义。

new a = copy (new a, new b) {
  print(a, b)
}(1, 2)

那么既然可以这样使用构造函数,那普通的函数应该怎么定义呢?

很简单,不使用copy关键字就可以了

new a = (new a, new b) {
  print(a, b)
}

和我们上文中的定义一样,也就是说,函数不需要任何特别的关键字,他和变量的定义过程一样,区别就在于函数可以有参数和返回值。

如果一个函数内部只有成员定义,却没有任何逻辑,那不就是一个结构体吗?

比如

new a = {
  new a = 1
  new b = {
    new c =3
  }
}

是的,我就打算这样构建新的数据类型。甚至你可以定义一个有参数的数据类型

new a = (new a=1) {
 new b ={
    new c = 3
  }
}

这样我们在创建变量的时候就可以像调用函数一样创建变量。

new b = copy a(2)

如果我们不传递参数,a就会默认为1

new b = copy a()

在上面的讨论中,{}用来表示一个区块,在内存中它是一个对象,在程序中,他是一个函数,或者使用我们新的词,模板。也就是说模板是程序中的概念,不是内存中的概念,也许应该添加一个新的关键词了。