多方安全计算中的半诚信和恶意安全
前言:
本文为阿里CRO密码学与隐私保护技术负责人:洪澄老师 ,在“隐语开源社区meet up 北京场“的分享内容。从什么是半诚实模型和恶意模型讲起,以OT协议、神经网络推理、ECDH PSI作为例子,讲述了恶意安全性的重要性。随后围绕恶意安全性常见的三大误解进行一一纠正,探讨了学术届与工业界对恶意安全模型隐私性&正确性要求的异同。最后展开分享了实现恶意模型的两种方案:消息校验码MAC,以及DZKP分布式零知识证明。点击可跳转观看:全场内容精华+视频
哔哩哔哩,,,
安全多方计算中的半诚实模型与恶意模型
小程序
首先从什么是半诚实模型和恶意模型讲起。
在讲半诚实模型和恶意模型之前,先介绍安全多方计算。通俗地讲,安全多方计算是多人合作计算一个函数,并且除了这个函数的计算结果之外,参与者得不到任何其他消息,这就是安全多方计算。大家比较熟知的是姚氏百万富翁问题,即一个人想跟另一个人比较谁的财富多,但是互相不想透露给对方自己具体的财富值。那么,安全多方计算是否可以无条件达到除计算结果之外不泄露任何中间信息的安全目标呢?答案是:它是有条件的。
条件之一就是到底存在什么样的攻击者。大体可以将攻击者划分为两种:一种是半诚实攻击者,会忠实地按照既定的协议去运行,比如百万富翁问题规定比大小那就只是比大小,再比如打牌的时候要先切牌再抽牌,那就必然先切牌再抽牌,不会作弊;还有一种攻击者则是恶意攻击者,也就是并不会按照协议规定去做,比如规定是比大小,但其可能会更改协议运行,试图从中得到一些别的好处。
对于这两种攻击者防御他们的难度显然是不一样的,如果能防御半诚实的攻击者,那这种安全多方计算协议就满足半诚实安全性,它只需要保证这个协议的过程中不会泄露任何其他信息,比如说百万富翁问题比大小,除了大小结果没有任何中间泄露即可;如果我们要抵挡恶意攻击者的话,那我们不仅要满足半诚实安全性,我们还需要额外的机制来验证参与方是否按照预定的协议去运行,比如说不仅要保证比大小的过程中不会泄露其他信息,还要强制确保除了比大小之外,没有进行其他不相干的事。那么恶意安全性到底有没有必要呢?我们通过一个例子来看:
一个协议满足半诚实安全性,但是恶意敌手可能会将它攻破。如一个OT协议是半诚实安全的(OT即不经意传输:Bob有两条数据,Alice想抽其中的一条,但是她不想告诉Bob具体是哪一条,最后Alice拿到一条且Bob并不知道是哪一条),一个简单的构造方法是,Alice构造两个公钥,并知晓其中一个的离散对数(也就是私钥),然后Bob用这两个公钥加密两条数据发回。因为Alice知道其中一条私钥,所以可以解密其中一个,并且Bob不知道解密的是哪一个,因为对于Bob来说,两个公钥是没有差别的。在半诚实情况下,因为Alice只有其中一个的离散对数,那么只能解密其中一个,确实只拿到了其中一条数据。但是如果Alice是恶意的,她只需要构造两个都知晓私钥的公钥,即可将Bob返回的两条数据都解密,就可以成功危害到Bob的隐私。这个例子可以看出,在某些情况下如果协议只做到半诚实安全是不够的。(此例只说一种OT协议,不是说所有半诚实OT协议都有这种危险,因为OT协议有很多种构造方法。)再举一个例子说神经网络推理:
以保险场景为例,服务器有一个神经网络模型,需要以客户的病历为输入,从而判断是否可以投保或者保费如何定价。那么安全神经网络推理就是要求服务器不能知道客户的病历细节,客户也不能知道服务器的模型逻辑,最后双方只要一个推理结果,就是保费定价是多少。安全神经网络推理是可以满足半诚实安全的,如我们去年的猎豹就已经做到了,并且是目前世界上最快的安全神经网络推理。但是,如果这个客户是恶意的,他是可以通过多次推理来推算出服务器的模型参数的。例如一个恶意的客户他的输入是没有限制的,他可以先输入一个全零的数据,到最后一层时把自己的share加上1,此时服务器神经网络的最后一层数据就被他获取了,进而他可以再来一次推导第二层,即使倒数第二层有点难,有一个非线型的Relu没法直接解出,但是客户再进行一个恶意操作,将自己的Share加上一个巨大的正数, Relu就无效化了,就变成了纯线性变化,此时如同解倒数第一层一样,倒数第二层也可以被解出,由此类推,直至整个服务器的模型全部被解出,这明显这是违背了服务器的隐私需求的。
这篇工作叫做Muse,来自USENIX’Sec21,因其是解方程的原理,需要推理的次数跟模型的参数个数相关,但绝对不大于模型参数的个数,可能远小于模型的个数即可解出方程,如几千次到几万次推理,就可以将整个服务器的模型都窃取过来。可以看到我们半诚实的安全多方计算推理工作是没办法抵挡这种攻击的。再来一个例子,说明半诚实安全的协议并不一定会被恶意攻击者完全破解,如PSI中我们经常用的ECDH PSI:
大家可能经常用这个ECDH的PSI。首先这个协议只是半诚实的,因为并没有对对方发过来的数做任何检测,一个恶意攻击者可以让这些协议产生一些非预期的结果。上海交大的郁昱教授团队曾经做了一项工作:如果恶意的Bob跟Alice进行多次合作,这个恶意的Bob可以篡改自己的数据来得到Alice数据的交集,即Alice两次合作中有没有重复数据,这显然是PSI中额外的信息。但是,要想获取Alice的原文依然是困难的。所以,半诚实协议遭到恶意攻击时究竟会造成何种损失,是需要case by case去看的。讲完了半诚实跟恶意的定义之后,我们来看一些常见的误解。第一个误解:如果做到恶意安全性,就可以防止机器学习中的投毒攻击了。
答案是并不能,因为恶意安全性不防换输入,而投毒是将自己的输入更换,这不属于恶意攻击的范畴。恶意攻击是保证发过来的数据确实对应了一个输入,但这个输入是否是原始数据,协议密码学则无法保证。如百万富翁协议,我实际有100元,但输入却是100万,这个密码协议检查不了,协议只能保证输入的数确实是一个钱数。CCS’22也收录了一篇文章:可以通过投毒攻击来攻破各种隐私计算框架,包括具备恶意安全性的MPC框架。投毒攻击需要通过其他的机制来保护,而不能仅通过实现协议的恶意安全性来保护。第二个误解:只要做到恶意安全性就可以防止合谋攻击。
答案是并不能,恶意安全性与合谋攻击是两个完全独立的安全指标,一个恶意安全的模型可能没法抗合谋,比如说三方计算里面如果两个人合谋可以危害第三个人的隐私,这是完全可能的。而一个半诚实安全的协议它完全可能可以抗合谋,如点对点的两方计算是抗合谋攻击的,但只能满足半诚实安全性,也就是说它两个属性是完全独立的。第三个误解:通过审计日志检测攻击,就实现了恶意安全性。
答案是事后的检查并不能达到恶意安全性,恶意安全性的定义是需要在攻击者成功泄露任何信息之前就终止协议,这叫做恶意安全性with abort(终止),当然这只是恶意安全性最低要求,更高要求是就算有恶意攻击者,整个协议还是能够正常的运行,这个安全性要求就非常高,不是所有的场景都能达到。此处我们不展开探讨,只要满足abort就认为能达到恶意安全性。接下来一起看看现在工业界的标准中对恶意模型有什么要求,结合实际生产进行探讨。
第一个是国际标准ISO 4922-1,另一个是国内信通院的行标《隐私计算产品安全测试方法》,它们都对恶意安全性提出了类似的两个要求,第一个要求隐私性,第二个要求正确性。
学术界常常将这两个指标合二为一,因为隐私性与正确性是很难严谨的分开的。例:Alice和Bob想计算一个函数F,现有一个恶意攻击者攻击导致最后结果变成了G,除了显然违背了正确性,但结果G泄露是否多于F有时候无从比较,所以有没有违反隐私性无法定论。所以学术界一般不区分两者,通常可以通过一个模拟器构造完全随机的交互内容,这个内容跟真实交互是不可区分的,如此隐私性跟正确性就同时保证,只要一个证明就全部覆盖。
工业界为什么需要区分隐私性和正确性呢?我们曾参加了ISO标准的讨论,也参加了信通院这项行业标准的讨论,并且都支持这两个指标的拆分。因为工业中确实存在一些需要区分的场景,如数据方、结果方、计算方分离,数据方以秘密共享的方式输入数据到计算节点后下线,由计算方计算并将结果直接发送给另外的结果方,这里面计算方是不获取任何结果的,结算方就算做任何的恶意行为都无法获得更多的信息,充其量是对正确性造成一些破坏。所以在这个场景中,只需要计算方执行一个半诚实协议就可以保证针对计算方的隐私性。但要实现正确性难度就比较大,如果我们要实现正确性,只能做一个真*恶意安全的协议执行才行。可以看到在这个里面隐私性和正确性出现了一个阶梯性的区分,所以划分成两个不同的要求,主要是与特殊的场景所对应的。恶意模型在学术上有非常重要的意义,而且在工业界也有这样的标准要求,所以接下来我们探讨怎样实现恶意模型。
核心思想是要提供一种机制,让各个参与方向其他的参与方自证行为是正确的,没有违背协议。这个机制有很多种,比如MAC或分布式ZKP零知识证明,我们简要介绍一下。第一个方案是消息校验码MAC,MAC主要用在不诚实大多数中,首先需要一个私钥,这个私钥秘密共享放在两方之间,也就是说没有任何一方知道私钥是多少。那么一个数据的MAC值就是私钥乘以数据,也是秘密共享的。有了MAC之后,原来在半诚实协议下是做秘密共享,但现在所有的秘密共享都是带着MAC值的,包括乘法三元组也都是带MAC的。
可以看到在这种状态下,做加法、常数乘法都跟半诚实一样的,本地完成就行了,因为MAC是线性的。不同之处在于,做乘法的时候需要额外的步骤,熟悉MPC算法的同学知道,拿着乘法三元组运行如图的协议就可以实现乘法,但是在恶意协议里面这一步行不通,这里面需要把E和D复原出来,这里面没办法保证对方发过来的E是正确的,所以需要MAC来验证E跟D的正确性。
检查E跟D的正确性,首先如同半诚实一样将E复原出来,然后需要将即将E的秘密共享跟E的MAC的秘密共享做一个计算,然后把计算结果互相发送,如果结果是0则E是正确的,如果不是0则E就是错的,便是发现了恶意行为,就终止协议。这里有一个小小的思考:为什么要“commit to z”?因为网络延迟总有一方是早一点得到那个Z的,那么如果他恶意的返回发送一个负Z,结果是0就对对方形成了欺骗,不能允许这样的行为出现,这就需要commit,事先对后面要发的z形成一个约束,不能恶意发送。通过这样的步骤可以成功完成恶意安全的乘法。基本上有了乘法跟加法之后就可以构成任何协议了,这里面核心难点是我们需要三元组也是带MAC的,带三元组MAC的生成是一个非常困难的事情,而且历史也悠久,已经有非常多的研究,从2012年的SPDZ开始,MASCOT,Overdrive,它一直在快速的发展中,目前还在不停的进展。一句话总结,目前恶意安全的MAC的乘法三元组生成协议,它的成本达到了半诚实的100倍以上,唯一的好处就是它可以在预处理阶段完成,跟真正的数据没有关系。这个100倍还是有优化空间的,我们可以在这方面再进行一些研究。
第二种是零知识证明,前面的D即分布式零知识证明,这个主要用在诚实大多数的场景。我们看一个半诚实的协议ABY3,ABY3的乘法是很简单的,三个share乘以三个share,就是9项,每人算3项即可。但是如果放到恶意协议里面这么做就不够了,P2需要知道P1发过来的三项是对的才行,需要把这个等式记做一个五元方程,这里一共出现了五个未知数,其中有三个未知数是P2知道,有两个未知数是P3知道,如果用普通的零知识证明是无法做到的,因为一个验证者有两个未知数不知道,所以P2和P3必须合作一起验证P1是否做了正确的事。
P1首先通过插值生成5个一次多项式F1-F5,然后把这5个一次多项式乘起来得到一个二次多项式,把二次多项式的系数以秘密分享的方式发送给另外两方,另外两方各得到了一个二次多项式,他们检查一下这个二次多项式在P1点的值是不是0,如果是0就确认成立。
但这还不够,因为还无法确认发过来的多项式是真的用五个未知数构造的,还是随便发了一个P1等于0的多项式,还需要如下的协议,首先我们刚才插值用到了五个随机数,随机数需要以秘密共享的方式发送给另外两个人,另外两个人根据未知数的秘密共享以及自己知道的那个未知数,每个人都生成五个一次多项式,P2可以生成五个,P3可以生成五个,P2和P3协商一个随机数,计算这5个多项式,10个多项式在随机数上的取值再加起来,可见这个东西就是F1在R上的取值。然后把这么多项都在R上计算一下,跟P1发过来的多项式在R上的结果对比一下看是否成立,成立则说明这个多项式真的是用我们共同知道的那五个未知数构造的。这个验证就通过了,证明收到的那些数真的是P1诚实构造的。由于ABY3的乘法要做三次,三个自证之后乘法就满足恶意安全性了。这与半诚实协议相比额外的代价首先来自于插值生成多项式。虽然这个例子里面只是一次多项式,但是如果并行验证很多个乘法,这会是一个不只一次的多项式,插值过程还是耗不少计算量的,插值之后高次多项式的相乘也是比较耗计算量的。它的成本来说,是半诚实的2-10倍,如ABY3可能会达到10倍,而经过一些优化可能会达到2倍。看了目前两种主流恶意模型的实现方式,可以总结一下,目前半诚实模型安全多方计算的研究已经接近极限,low hanging fruit基本上都被摘完了,例如半诚实的神经网络推理,在猎豹成果基础上再大幅提升已经非常困难了。接下来在半诚实方面还能做的工作可能是硬件或者网络优化,或者说针对一个特定的场景进行一些特定的应用优化,密码学角度是很难再改进了。
而恶意模型具备更强的安全性,而且确实存在一些高安全场景,没法假设只有半诚实敌手,确实可能存在恶意的攻击者。但是目前恶意模型方面可以直接用的成果还比较少,因为成本远超于半诚实模型,半诚实模型已经比明文慢不少倍了,恶意模型慢更多倍。从好处看,说明恶意模型这方面的研究还有很多可以做,里面涉及不少额外的技术栈,包括零知识证明等等。我们在这方面已经取得了一定的成果,并且这个成果将来会贡献到隐语中,有望让隐语成为国内唯一一个具备恶意安全性的隐私计算开源框架。以上就是我今天的报告内容,谢谢大家。
推荐阅读
-
多方安全计算中的半诚信和恶意安全
-
小红书大产品部架构 小红书产品概览--经过性能、稳定性、成本等多个维度的详细评估,小红书最终决定选择基于腾讯云星海自研硬件的SA2云服务器作为主力机型使用。结合其秒级的快速扩缩、超强兼容和平滑迁移能力,小红书在抵御上亿次用户访问、保证系统稳定运行的同时,也实现了成本的大幅降低。 星海SA2云服务器是基于腾讯云星海的首款自研服务器。腾讯云星海作为自研硬件品牌,通过创新的高兼容性架构、简洁可靠的自主设计,结合腾讯自身业务以及百万客户上云需求的特点,致力于为云计算时代提供安全、稳定、性能领先的基础架构产品和服务。如今,星海SA2云服务器也正在为越来越多的企业提供低成本、高效率、更安全的弹性计算服务。 以下是与小红书SRE总监陈敖翔的对话实录。 问:请您介绍一下小红书及其主要商业模式? 小红书是一个面向年轻人的生活方式平台,在这里,他们发现了向上、多元的真实世界。小红书日活超过 3500 万,月活跃用户超过 1 亿,日均笔记曝光量达 80 亿。小红书由社交平台和在线购物两大部分组成。与其他线上平台相比,小红书的内容基于真实的口碑分享,播种不止于线上,还为线下实体店赋能。 问:围绕业务发展,小红书的系统架构经历了怎样的变革和演进? 系统架构变化不大,影响最深的是资源开销。过去三年,资源开销大幅增加,同比增长约 10 倍。在此背景下,我们努力进行优化,包括很早就开始使用 K8S 进行资源调度。到 18 年年中,绝大多数服务已经完全实现了容器化。 问:目前小红书系统架构中的计算基础设施建设和布局是怎样的? 我们目前的建设方式可以简单描述为星型结构。腾讯云在上海的一个区是我们的计算中心,承载着我们的核心数据和在线业务。在外围,我们还有两个数据中心进行计算分流,同时承担灾备和线上业务双活的角色。 与其他新兴电子商务互联网公司类似,小红书的大部分计算能力主要用于线下数据分析、模型训练和在线推荐等平台。随着业务的发展,对算力的需求也在加速增长。
-
windows下进程间通信的(13种方法)-摘 要 本文讨论了进程间通信与应用程序间通信的含义及相应的实现技术,并对这些技术的原理、特性等进行了深入的分析和比较。 ---- 关键词 信号 管道 消息队列 共享存储段 信号灯 远程过程调用 Socket套接字 MQSeries 1 引言 ---- 进程间通信的主要目的是实现同一计算机系统内部的相互协作的进程之间的数据共享与信息交换,由于这些进程处于同一软件和硬件环境下,利用操作系统提供的的编程接口,用户可以方便地在程序中实现这种通信;应用程序间通信的主要目的是实现不同计算机系统中的相互协作的应用程序之间的数据共享与信息交换,由于应用程序分别运行在不同计算机系统中,它们之间要通过网络之间的协议才能实现数据共享与信息交换。进程间通信和应用程序间通信及相应的实现技术有许多相同之处,也各有自己的特色。即使是同一类型的通信也有多种的实现方法,以适应不同情况的需要。 ---- 为了充分认识和掌握这两种通信及相应的实现技术,本文将就以下几个方面对这两种通信进行深入的讨论:问题的由来、解决问题的策略和方法、每种方法的工作原理和实现、每种实现方法的特点和适用的范围等。 2 进程间的通信及其实现技术 ---- 用户提交给计算机的任务最终都是通过一个个的进程来完成的。在一组并发进程中的任何两个进程之间,如果都不存在公共变量,则称该组进程为不相交的。在不相交的进程组中,每个进程都独立于其它进程,它的运行环境与顺序程序一样,而且它的运行环境也不为别的进程所改变。运行的结果是确定的,不会发生与时间相关的错误。 ---- 但是,在实际中,并发进程的各个进程之间并不是完全互相独立的,它们之间往往存在着相互制约的关系。进程之间的相互制约关系表现为两种方式: ---- (1) 间接相互制约:共享CPU ---- (2) 直接相互制约:竞争和协作 ---- 竞争——进程对共享资源的竞争。为保证进程互斥地访问共享资源,各进程必须互斥地进入各自的临界段。 ---- 协作——进程之间交换数据。为完成一个共同任务而同时运行的一组进程称为同组进程,它们之间必须交换数据,以达到协作完成任务的目的,交换数据可以通知对方可以做某事或者委托对方做某事。 ---- 共享CPU问题由操作系统的进程调度来实现,进程间的竞争和协作由进程间的通信来完成。进程间的通信一般由操作系统提供编程接口,由程序员在程序中实现。UNIX在这个方面可以说最具特色,它提供了一整套进程间的数据共享与信息交换的处理方法——进程通信机制(IPC)。因此,我们就以UNIX为例来分析进程间通信的各种实现技术。 ---- 在UNIX中,文件(File)、信号(Signal)、无名管道(Unnamed Pipes)、有名管道(FIFOs)是传统IPC功能;新的IPC功能包括消息队列(Message queues)、共享存储段(Shared memory segment)和信号灯(Semapores)。 ---- (1) 信号 ---- 信号机制是UNIX为进程中断处理而设置的。它只是一组预定义的值,因此不能用于信息交换,仅用于进程中断控制。例如在发生浮点错、非法内存访问、执行无效指令、某些按键(如ctrl-c、del等)等都会产生一个信号,操作系统就会调用有关的系统调用或用户定义的处理过程来处理。 ---- 信号处理的系统调用是signal,调用形式是: ---- signal(signalno,action) ---- 其中,signalno是规定信号编号的值,action指明当特定的信号发生时所执行的动作。 ---- (2) 无名管道和有名管道 ---- 无名管道实际上是内存中的一个临时存储区,它由系统安全控制,并且独立于创建它的进程的内存区。管道对数据采用先进先出方式管理,并严格按顺序操作,例如不能对管道进行搜索,管道中的信息只能读一次。 ---- 无名管道只能用于两个相互协作的进程之间的通信,并且访问无名管道的进程必须有共同的祖先。 ---- 系统提供了许多标准管道库函数,如: pipe——打开一个可以读写的管道; close——关闭相应的管道; read——从管道中读取字符; write——向管道中写入字符; ---- 有名管道的操作和无名管道类似,不同的地方在于使用有名管道的进程不需要具有共同的祖先,其它进程,只要知道该管道的名字,就可以访问它。管道非常适合进程之间快速交换信息。 ---- (3) 消息队列(MQ) ---- 消息队列是内存中独立于生成它的进程的一段存储区,一旦创建消息队列,任何进程,只要具有正确的的访问权限,都可以访问消息队列,消息队列非常适合于在进程间交换短信息。 ---- 消息队列的每条消息由类型编号来分类,这样接收进程可以选择读取特定的消息类型——这一点与管道不同。消息队列在创建后将一直存在,直到使用msgctl系统调用或iqcrm -q命令删除它为止。 ---- 系统提供了许多有关创建、使用和管理消息队列的系统调用,如: ---- int msgget(key,flag)——创建一个具有flag权限的MQ及其相应的结构,并返回一个唯一的正整数msqid(MQ的标识符); ---- int msgsnd(msqid,msgp,msgsz,msgtyp,flag)——向队列中发送信息; ---- int msgrcv(msqid,cmd,buf)——从队列中接收信息; ---- int msgctl(msqid,cmd,buf)——对MQ的控制操作; ---- (4) 共享存储段(SM) ---- 共享存储段是主存的一部分,它由一个或多个独立的进程共享。各进程的数据段与共享存储段相关联,对每个进程来说,共享存储段有不同的虚拟地址。系统提供的有关SM的系统调用有: ---- int shmget(key,size,flag)——创建大小为size的SM段,其相应的数据结构名为key,并返回共享内存区的标识符shmid; ---- char shmat(shmid,address,flag)——将当前进程数据段的地址赋给shmget所返回的名为shmid的SM段; ---- int shmdr(address)——从进程地址空间删除SM段; ---- int shmctl (shmid,cmd,buf)——对SM的控制操作; ---- SM的大小只受主存限制,SM段的访问及进程间的信息交换可以通过同步读写来完成。同步通常由信号灯来实现。SM非常适合进程之间大量数据的共享。 ---- (5) 信号灯 ---- 在UNIX中,信号灯是一组进程共享的数据结构,当几个进程竞争同一资源时(文件、共享内存或消息队列等),它们的操作便由信号灯来同步,以防止互相干扰。 ---- 信号灯保证了某一时刻只有一个进程访问某一临界资源,所有请求该资源的其它进程都将被挂起,一旦该资源得到释放,系统才允许其它进程访问该资源。信号灯通常配对使用,以便实现资源的加锁和解锁。 ---- 进程间通信的实现技术的特点是:操作系统提供实现机制和编程接口,由用户在程序中实现,保证进程间可以进行快速的信息交换和大量数据的共享。但是,上述方式主要适合在同一台计算机系统内部的进程之间的通信。 3 应用程序间的通信及其实现技术 ---- 同进程之间的相互制约一样,不同的应用程序之间也存在竞争和协作的关系。UNIX操作系统也提供一些可用于应用程序之间实现数据共享与信息交换的编程接口,程序员可以通过自己编程来实现。如远程过程调用和基于TCP/IP协议的套接字(Socket)编程。但是,相对普通程序员来说,它们涉及的技术比较深,编程也比较复杂,实现起来困难较大。 ---- 于是,一种新的技术应运而生——通过将有关通信的细节完全掩盖在某个独立软件内部,即底层的通讯工作和相应的维护管理工作由该软件内部来实现,用户只需要将通信任务提交给该软件去完成,而不必理会它的具体工作过程——这就是所谓的中间件技术。 ---- 我们在这里分别讨论这三种常用的应用程序间通信的实现技术——远程过程调用、会话编程技术和MQSeries消息队列技术。其中远程过程调用和会话编程属于比较低级的方式,程序员参与的程度较深,而MQSeries消息队列则属于比较高级的方式,即中间件方式,程序员参与的程度较浅。 ---- 4.1 远程过程调用(RPC)
-
F#探险之旅(二):函数式编程(上)-函数式编程范式简介 F#主要支持三种编程范式:函数式编程(Functional Programming,FP)、命令式编程(Imperative Programming)和面向对象(Object-Oriented,OO)的编程。回顾它们的历史,FP是最早的一种范式,第一种FP语言是IPL,产生于1955年,大约在Fortran一年之前。第二种FP语言是Lisp,产生于1958,早于Cobol一年。Fortan和Cobol都是命令式编程语言,它们在科学和商业领域的迅速成功使得命令式编程在30多年的时间里独领风骚。而产生于1970年代的面向对象编程则不断成熟,至今已是最流行的编程范式。有道是“*代有语言出,各领风骚数十年”。 尽管强大的FP语言(SML,Ocaml,Haskell及Clean等)和类FP语言(APL和Lisp是现实世界中最成功的两个)在1950年代就不断发展,FP仍停留在学院派的“象牙塔”里;而命令式编程和面向对象编程则分别凭着在商业领域和企业级应用的需要占据领先。今天,FP的潜力终被认识——它是用来解决更复杂的问题的(当然更简单的问题也不在话下)。 纯粹的FP将程序看作是接受参数并返回值的函数的集合,它不允许有副作用(side effect,即改变了状态),使用递归而不是循环进行迭代。FP中的函数很像数学中的函数,它们都不改变程序的状态。举个简单的例子,一旦将一个值赋给一个标识符,它就不会改变了,函数不改变参数的值,返回值是全新的值。 FP的数学基础使得它很是优雅,FP的程序看起来往往简洁、漂亮。但它无状态和递归的天性使得它在处理很多通用的编程任务时没有其它的编程范式来得方便。但对F#来说这不是问题,它的优势之一就是融合了多种编程范式,允许开发人员按照需要采用最好的范式。 关于FP的更多内容建议阅读一下这篇文章:Why Functional Programming Matters(中文版)。F#中的函数式编程 从现在开始,我将对F#中FP相关的主要语言结构逐一进行介绍。标识符(Identifier) 在F#中,我们通过标识符给值(value)取名字,这样就可以在后面的程序中引用它。通过关键字let定义标识符,如: let x = 42 这看起来像命令式编程语言中的赋值语句,两者有着关键的不同。在纯粹的FP中,一旦值赋给了标识符就不能改变了,这也是把它称为标识符而非变量(variable)的原因。另外,在某些条件下,我们可以重定义标识符;在F#的命令式编程范式下,在某些条件下标识符的值是可以修改的。 标识符也可用于引用函数,在F#中函数本质上也是值。也就是说,F#中没有真正的函数名和参数名的概念,它们都是标识符。定义函数的方式与定义值是类似的,只是会有额外的标识符表示参数: let add x y = x + y 这里共有三个标识符,add表示函数名,x和y表示它的参数。关键字和保留字关键字是指语言中一些标记,它们被编译器保留作特殊之用。在F#中,不能用作标识符或类型的名称(后面会讨论“定义类型”)。它们是: abstract and as asr assert begin class default delegate do donedowncast downto elif else end exception extern false finally forfun function if in inherit inline interface internal land lazy letlor lsr lxor match member mod module mutable namespace new nullof open or override private public rec return sig static structthen to true try type upcast use val void when while with yield 保留字是指当前还不是关键字,但被F#保留做将来之用。可以用它们来定义标识符或类型名称,但编译器会报告一个警告。如果你在意程序与未来版本编译器的兼容性,最好不要使用。它们是: atomic break checked component const constraint constructor continue eager event external fixed functor global include method mixinobject parallel process protected pure sealed trait virtual volatile 文字值(Literals) 文字值表示常数值,在构建计算代码块时很有用,F#提供了丰富的文字值集。与C#类似,这些文字值包括了常见的字符串、字符、布尔值、整型数、浮点数等,在此不再赘述,详细信息请查看F#手册。 与C#一样,F#中的字符串常量表示也有两种方式。一是常规字符串(regular string),其中可包含转义字符;二是逐字字符串(verbatim string),其中的(")被看作是常规的字符,而两个双引号作为双引号的转义表示。下面这个简单的例子演示了常见的文字常量表示: let message = "Hello World"r"n!" // 常规字符串let dir = @"C:"FS"FP" // 逐字字符串let bytes = "bytes"B // byte 数组let xA = 0xFFy // sbyte, 16进制表示let xB = 0o777un // unsigned native-sized integer,8进制表示let print x = printfn "%A" xlet main = print message; print dir; print bytes; print xA; print xB; main Printf函数通过F#的反射机制和.NET的ToString方法来解析“%A”模式,适用于任何类型的值,也可以通过F#中的print_any和print_to_string函数来完成类似的功能。值和函数(Values and Functions) 在F#中函数也是值,F#处理它们的语法也是类似的。 let n = 10let add a b = a + blet addFour = add 4let result = addFour n printfn "result = %i" result 可以看到定义值n和函数add的语法很类似,只不过add还有两个参数。对于add来说a + b的值自动作为其返回值,也就是说在F#中我们不需要显式地为函数定义返回值。对于函数addFour来说,它定义在add的基础上,它只向add传递了一个参数,这样对于不同的参数addFour将返回不同的值。考虑数学中的函数概念,F(x, y) = x + y,G(y) = F(4, y),实际上G(y) = 4 + y,G也是一个函数,它接收一个参数,这个地方是不是很类似?这种只向函数传递部分参数的特性称为函数的柯里化(curried function)。 当然对某些函数来说,传递部分参数是无意义的,此时需要强制提供所有参数,可是将参数括起来,将它们转换为元组(tuple)。下面的例子将不能编译通过: let sub(a, b) = a - blet subFour = sub 4 必须为sub提供两个参数,如sub(4, 5),这样就很像C#中的方法调用了。 对于这两种方式来说,前者具有更高的灵活性,一般可优先考虑。 如果函数的计算过程中需要定义一些中间值,我们应当将这些行进行缩进: let halfWay a b = let dif = b - a let mid = dif / 2 mid + a 需要注意的是,缩进时要用空格而不是Tab,如果你不想每次都按几次空格键,可以在VS中设置,将Tab字符自动转换为空格;虽然缩进的字符数没有限制,但一般建议用4个空格。而且此时一定要用在文件开头添加#light指令。作用域(Scope)作用域是编程语言中的一个重要的概念,它表示在何处可以访问(使用)一个标识符或类型。所有标识符,不管是函数还是值,其作用域都从其声明处开始,结束自其所处的代码块。对于一个处于最顶层的标识符而言,一旦为其赋值,它的值就不能修改或重定义了。标识符在定义之后才能使用,这意味着在定义过程中不能使用自身的值。 let defineMessage = let message = "Help me" print_endline message // error 对于在函数内部定义的标识符,一般而言,它们的作用域会到函数的结束处。 但可使用let关键字重定义它们,有时这会很有用,对于某些函数来说,计算过程涉及多个中间值,因为值是不可修改的,所以我们就需要定义多个标识符,这就要求我们去维护这些标识符的名称,其实是没必要的,这时可以使用重定义标识符。但这并不同于可以修改标识符的值。你甚至可以修改标识符的类型,但F#仍能确保类型安全。所谓类型安全,其基本意义是F#会避免对值的错误操作,比如我们不能像对待字符串那样对待整数。这个跟C#也是类似的。 let changeType = let x = 1 let x = "change me" let x = x + 1 print_string x 在本例的函数中,第一行和第二行都没问题,第三行就有问题了,在重定义x的时候,赋给它的值是x + 1,而x是字符串,与1相加在F#中是非法的。 另外,如果在嵌套函数中重定义标识符就更有趣了。 let printMessages = let message = "fun value" printfn "%s" message; let innerFun = let message = "inner fun value" printfn "%s" message innerFun printfn "%s" message printMessages 打印结果: fun value inner fun valuefun value 最后一次不是inner fun value,因为在innerFun仅仅将值重新绑定而不是赋值,其有效范围仅仅在innerFun内部。递归(Recursion)递归是编程中的一个极为重要的概念,它表示函数通过自身进行定义,亦即在定义处调用自身。在FP中常用于表达命令式编程的循环。很多人认为使用递归表示的算法要比循环更易理解。 使用rec关键字进行递归函数的定义。看下面的计算阶乘的函数: let rec factorial x = match x with | x when x < 0 -> failwith "value must be greater than or equal to 0" | 0 -> 1 | x -> x * factorial(x - 1) 这里使用了模式匹配(F#的一个很棒的特性),其C#版本为: public static long Factorial(int n) { if (n < 0) { throw new ArgumentOutOfRangeException("value must be greater than or equal to 0"); } if (n == 0) { return 1; } return n * Factorial (n - 1); } 递归在解决阶乘、Fibonacci数列这样的问题时尤为适合。但使用的时候要当心,可能会写出不能终止的递归。匿名函数(Anonymous Function) 定义函数的时候F#提供了第二种方式:使用关键字fun。有时我们没必要给函数起名,这种函数就是所谓的匿名函数,有时称为lambda函数,这也是C#3.0的一个新特性。比如有的函数仅仅作为一个参数传给另一个函数,通常就不需要起名。在后面的“列表”一节中你会看到这样的例子。除了fun,我们还可以使用function关键字定义匿名函数,它们的区别在于后者可以使用模式匹配(本文后面将做介绍)特性。看下面的例子: let x = (fun x y -> x + y) 1 2let x1 = (function x -> function y -> x + y) 1 2let x2 = (function (x, y) -> x + y) (1, 2) 我们可优先考虑fun,因为它更为紧凑,在F#类库中你能看到很多这样的例子。 注意:本文中的代码均在F# 1.9.4.17版本下编写,在F# CTP 1.9.6.0版本下可能不能通过编译。 F#系列随笔索引页面