316 lines
18 KiB
TeX
316 lines
18 KiB
TeX
\documentclass[全部作业]{subfiles}
|
||
\usepackage{titlesec} % 只在这个文件里的序言里用到了\titlelabel
|
||
\usepackage{titletoc}
|
||
|
||
\pagestyle{fancyplain}
|
||
\fancyhead{}
|
||
\fancyhead[C]{\mysignature}
|
||
% \titlelabel{\thechapter}
|
||
% \renewcommand{\section}{\section*}
|
||
\titlelabel{} % 去除序号,但是在主文件中不去除
|
||
% \titleformat{\section}{}{}{0pt}{} % 去除section的序号但是不去除别的
|
||
|
||
\begin{document}
|
||
% appendices环境设置的服务好像还是用了“第一章”
|
||
% 因此使用\appendix命令,但是放到主文件里
|
||
|
||
\section{课程项目——函数式程序设计及其发展历史和研究现状综述}
|
||
\ifSubfilesClassLoaded{
|
||
\ctexset{section/numbering = false,
|
||
subsection/numbering = false,
|
||
subsection/format += \centering,
|
||
subsubsection/numbering = false,
|
||
secnumdepth = 0,
|
||
tocdepth = 3,
|
||
}
|
||
% \titlecontents*{lsubsection}[0pt]
|
||
% {\small\itshape}{}{}
|
||
% {}[ \textbullet\ ][.]
|
||
% \titlecontents{subsection}[0pt]{}{}{}{}[]
|
||
% \contentspush{12}
|
||
% \setcounter{tocdepth}{3}
|
||
\subsection{目录}
|
||
\startcontents
|
||
% \tableofcontents
|
||
\printcontents{l}{2}[3]{}
|
||
}{}
|
||
|
||
\subsection{摘要}
|
||
本文没有过多晦涩难懂的理论知识,例如函数式程序设计中的Monad、范畴等概念,也没有冗长的介绍发展历史,而是更多从实际使用的角度,讲述函数式程序设计在实际业务场景中如何使用。
|
||
\subsection{发展历史}
|
||
首先,让我们看一下函数式程序设计的定义。以下内容在维基百科(The Free Dictionary网站)中找到,这里认为“函数式程序设计”和“函数式编程”同义。
|
||
|
||
在计算机科学中,函数式编程是一种编程范式,其中程序是通过应用和组合函数来构造的。它是一种声明性编程范例,其中函数定义是将值映射到其他值的表达式树,而不是一系列命令式语句 更新 程序的运行状态 。\cite{wikidefination}
|
||
|
||
那么函数式程序设计的历史可以追溯到$\lambda $演算\cite{zhihuJS1},并且发展出了很多函数式编程语言,这里就不详细展开了。
|
||
|
||
\subsection{研究现状}
|
||
要真正搞懂函数式程序设计,我们先要明白什么是状态。以及状态和逻辑如何分离。
|
||
|
||
我们知道,程序是由输入、处理、输出组成。那么有一个直接的问题是我每次使用这个程序,并且给定相同的输入,是否都能得到同样的输出呢?如果只是从算法题(OJ, Online Judge)的角度看,那确实是这样。
|
||
|
||
我们遇到的算法题总是给你一些输入,让你输出某个结果,就像课程项目中的一部分题:“输入……,输出……”,这种题目的直观感受就是给定一个输入,那么必定会产生某个输出(甚至有时候只有唯一的正确输出)。
|
||
|
||
但是脱离算法题的框架呢?比如我要实现读取好几个文件的内容,之后把他们拼接起来放到一个新的文件里。这样的情况是不是就没有输入了?那文件不同,得到的输出结果也不同了。这就是一种典型的文件IO(输入输出)操作。
|
||
|
||
再举个例子,之前的数据结构课上的作业,要实现模拟队列动态变化,这个的输出又是什么呢?可以看到这个的输出其实是有时间维度的,在每个时间都应该输出不同的内容。因此这很难用无状态的方式来输出。那这个例子再扩展出去就是图形用户界面(GUI)了,这就是典型的输出不仅和状态有关,还和当前时刻的输入有关(也就类似数字逻辑及实验中的米利模型的有限状态机)。
|
||
|
||
因此,根据个人的理解,状态就是除了代码之外的,会影响程序的行为的东西。那么与之相对的,代码体现的就是程序的逻辑。因此,数据库、文件、外部服务,都可以看作是状态。
|
||
|
||
搞清楚了状态,我们接下来考虑为什么要分离状态和逻辑?主要的原因是为了便于备份和迁移。让我们先看一个例子:
|
||
|
||
假设有需求要请求一个网址并且把响应保存到本地。那么自然的写法可能是这样(用Python举例):
|
||
|
||
\begin{minted}{python}
|
||
import requests
|
||
url = input("请输入网址")
|
||
filename = input("请输入文件名")
|
||
res = requests.get(url)
|
||
with open(filename, "wb") as f:
|
||
f.write(res.content)
|
||
\end{minted}
|
||
|
||
但考虑如果要多次使用这个功能,那么每次使用,都会将响应保存到运行的工作目录下,而如果每次都在代码文件所在的目录下运行,那么时间长了,就会在这个目录里产生很多下载的文件,看起来非常杂乱。
|
||
|
||
假设在之后的某一天,我们想把这个功能的源代码加入版本控制中,并且放到开源平台,但又不想把下载的响应文件一起公开,那么还需要添加到忽略列表等操作。
|
||
|
||
又过了一段时间,需要备份这些下载的响应内容,那么只能把整个目录连带代码文件一起备份,但是代码已经使用版本控制进行管理了,再进行备份只能备份当前版本,这就非常冗余。
|
||
|
||
但是,如果将状态和逻辑进行了分离,也就是把响应全部放到同一个位置,和代码逻辑分离开,这样在备份和迁移的时候都非常方便,而这就需要用到函数式程序设计了。
|
||
|
||
接下来我们结合几个实际的应用场景来具体讲解函数式程序设计如何使用。
|
||
|
||
\subsubsection{Serverless}
|
||
目前,Serverless架构也算是一个比较活跃的话题,它主要是将静态的页面文件和动态的请求分离开,静态页面由云服务器的存储实现,并且还能方便实现CDN(内容分发网络)加速;而动态的请求也可以由云厂商提供的事件驱动的函数计算服务来实现,当收到请求后进行函数计算并返回结果给前端,这里就把整个应用拆分成了各个函数,因此需要用到函数式程序设计。
|
||
|
||
\subsubsection{数据预处理}
|
||
在深度学习的任务中,经常需要对数据进行预处理,而这些预处理可能分成很多步骤,并且有时候还需要把中间结果保存下载作为状态,以便下次使用时继续执行。比如自然语言处理的预处理可能就有分词、转词表、词嵌入。当数据量非常大的时候,如果中间某一步由于意外原因中断了,这时如果没有保存中间结果,那只能重新运行,这是非常浪费时间的。但是要保存中间结果就不可避免地要涉及到状态的存储。这时如果使用面向对象的方式,会是什么样的?
|
||
\begin{minted}{python}
|
||
class PreProcessing:
|
||
def tokenize(self, text):
|
||
...
|
||
with open(self.token_path, "w") as f:
|
||
f.write(result)
|
||
return result
|
||
|
||
def token_to_id(self, tokens):
|
||
...
|
||
with open(self.id_path, "w") as f:
|
||
for id_ in ids:
|
||
print(id_, file=f)
|
||
return ids
|
||
|
||
def id_to_embedding(self, ids):
|
||
...
|
||
paddle.save(embeddings, self.embedding_path)
|
||
return embeddings
|
||
|
||
\end{minted}
|
||
|
||
这样会产生一个问题,每一个流程如何向下一个流程传递数据?直接通过函数的返回值传递?还是通过文件读取和写入来传递?直接通过函数的返回值传递的话,那怎么实现接续上次的运行结果?那还需要再单独写读取的方法?这样就会导致调用时非常麻烦。那如果全部使用文件的读取和写入,也就是把上述代码改成
|
||
\begin{minted}{python}
|
||
class PreProcessing:
|
||
def tokenize(self):
|
||
...
|
||
with open(self.token_path, "w") as f:
|
||
f.write(result)
|
||
return result
|
||
|
||
def token_to_id(self):
|
||
tokens = open(self.token_path).read()
|
||
...
|
||
with open(self.id_path, "w") as f:
|
||
for id_ in ids:
|
||
print(id_, file=f)
|
||
return ids
|
||
|
||
def id_to_embedding(self, ids):
|
||
with open(self.id_path, "r") as f:
|
||
ids = [int(i) for i in f.readlines()]
|
||
...
|
||
paddle.save(embeddings, self.embedding_path)
|
||
return embeddings
|
||
|
||
\end{minted}
|
||
那这样的话,在第一次运行的时候,还需要判断文件是否存在,这也会导致复杂度提升。
|
||
|
||
并且最关键的问题是,从外部调用这些方法的时候,只看方法名称,不看文档,无法知道这个方法是有状态的还是无状态的,也就是说不知道这个方法是会把运行结果保存在文件中还是不保存,也不知道这个方法是否会从文件中读取内容。这样就非常不利于状态的统一管理。
|
||
而且还有一个问题是面向对象很难将原先功能改成多进程并行处理,因为整个对象中保存了很多状态,而有些状态是在并行处理中用不到的,有些状态可能是需要用到并且需要共享的,这些状态如果没有很好地区分,就很难进行并行处理。
|
||
|
||
于是可以尝试使用函数式的方式改写上述功能:
|
||
\begin{minted}{python}
|
||
from pathlib import Path
|
||
import asyncio
|
||
|
||
def tokenize(text):
|
||
...
|
||
return result
|
||
|
||
def token_to_id(token): # 注意这里的参数是单数,也就是单个token
|
||
...
|
||
return result
|
||
|
||
def id_to_embedding(ids): # 这里由于Embedding是矩阵形式,因此参数是复数
|
||
...
|
||
return result
|
||
|
||
def compose(*monads):
|
||
asyncio.run(asyncio.gather(*monads))
|
||
|
||
async def monad(function, in_func=None, out_func=None): # 这样命名可能不太对,因为确实不太清楚这样是否符合真正的Monad的定义
|
||
args = None
|
||
if in_func:
|
||
args = await in_func()
|
||
result = function(args)
|
||
if out_func:
|
||
asyncio.create_task(lambda :await out_func(result))
|
||
return result
|
||
|
||
def list_map(*args, **kwargs):
|
||
return list(map(*args, **kwargs))
|
||
|
||
main = lambda token_path, id_path, embedding_path : compose(
|
||
monad(tokenize,
|
||
in_func=None,
|
||
out_func=lambda x:Path(token_path).write_text(x)
|
||
),
|
||
monad(token_to_id,
|
||
in_func=lambda :Path(token_path).read_text(),
|
||
out_func=lambda file: lambda ids:list_map(
|
||
lambda id_ : print(id_, file=file),
|
||
ids)(open(id_path, "w")) # 这样写是为了完全使用函数式的写法,实际使用时如果这样写可读性非常差,而且Python有更多有用的语法,比如with,yield,列表解析式等,不太适合强行使用函数式
|
||
),
|
||
monad(id_to_embedding,
|
||
in_func=lambda :list_map(int, Path(id_path).read_text().splitlines()),
|
||
out_func=lambda embeddings:paddle.save(embeddings, embedding_path),
|
||
),
|
||
)
|
||
|
||
main(...)()
|
||
\end{minted}
|
||
|
||
由于时间所限,一些细节可能不是非常准确,但总体思路大致就是这样,使用了函数式程序设计后,能非常清晰地看出数据的流动过程,以及什么时候使用了IO,并且能非常方便地改为并发执行。
|
||
|
||
不过也能看到,函数式程序设计也可能会导致代码可读性下降,虽然也可能是写得不太规范的原因。
|
||
|
||
\subsubsection{装饰器}
|
||
装饰器实际上对应了函数式程序设计中高阶函数的概念,调用时传入一个函数,之后可以对这个函数进行修改,并且再返回。
|
||
|
||
例如,在Python中可以写一个测量函数调用需要的时间的装饰器:
|
||
\begin{minted}{python}
|
||
import time
|
||
def measure_time(function):
|
||
def inner(*args, **kwargs):
|
||
start_time = time.time()
|
||
result = function(*args, **kwargs)
|
||
print(time.time() - start_time)
|
||
return result
|
||
return inner
|
||
\end{minted}
|
||
之后如果要测量一个函数的使用时间,就可以这样
|
||
\begin{minted}[firstnumber=last]{python}
|
||
@measure_time
|
||
def test():
|
||
time.sleep(1)
|
||
|
||
test()
|
||
\end{minted}
|
||
|
||
那么在JavaScript中也可以有类似的实现(正式的JavaScript装饰器语法还在提案阶段,目前无法直接使用):
|
||
\begin{minted}{javascript}
|
||
const measure_time = func => (...args) => {
|
||
const start_time = new Date();
|
||
const result = func(...args);
|
||
console.log(new Date() - start_time);
|
||
return result;
|
||
}
|
||
|
||
function func1(args) {
|
||
do_some_thing(args);
|
||
}
|
||
|
||
const func = measure_time(func1);
|
||
func();
|
||
\end{minted}
|
||
|
||
这里使用了JavaScript的箭头函数的语法特性,这种语法特性能非常方便地实现函数式程序设计的多种特性,如函数部分参数调用,柯里(curry)化等,详细内容可见参考文献\cite{zhihuJS1, zhihuJS2}。
|
||
|
||
同样,在C++中也可以实现装饰器的概念。
|
||
\begin{minted}{cpp}
|
||
#include <functional>
|
||
#include <ctime>
|
||
|
||
template <typename RET, typename ... ARGS>
|
||
std::function<RET(ARGS... args)> measure_time(std::function<RET(ARGS... args)> func) {
|
||
return [=](ARGS... args) -> RET {
|
||
time_t start_time = time(nullptr);
|
||
RET result = func(args...);
|
||
std::cout << time(nullptr) - start_time << std::endl;
|
||
return result;
|
||
}
|
||
}
|
||
|
||
std::function<void(void)> do_something;
|
||
|
||
const func = measure_time(do_something);
|
||
func();
|
||
\end{minted}
|
||
|
||
上述代码参考\cite{zhihuc++1},同样也没经过测试,但大致思路体现了函数式程序设计的思想,包括lambda匿名函数等特性。
|
||
|
||
虽然不知道各种编程语言的装饰器是否是受到了函数式程序设计的启发,但可以看到它们都能实现装饰器的功能,这也是函数式程序设计在现代发展的体现。
|
||
|
||
\subsubsection{\LaTeX 排版}
|
||
另一个非常需要函数式程序设计的思维的地方就是\LaTeX 排版了,在使用\LaTeX 的过程中,我曾经有这样一个需求:要在一个列表环境中使用多个\mintinline{latex}|\item|,但是它们有不同序号格式,比如有的格式是\mintinline{latex}|P\arabic{enumi}.|有的格式是\mintinline{latex}|R\arabic{enumi}.|,但是又希望序号能连续。因此在以前,只能使用\mintinline{latex}|\item[]|单独为每一项指定带格式的序号。但是在了解了函数式程序设计后,同时结合\LaTeX3的语法,写出了如下的\LaTeX 代码:
|
||
\begin{minted}{latex}
|
||
\def\getenum{%
|
||
\ifnum\EnumitemId=1%
|
||
enumi%
|
||
\else
|
||
\ifnum\EnumitemId=2%
|
||
enumii%
|
||
\else
|
||
\ifnum\EnumitemId=3%
|
||
enumiii%
|
||
\else%
|
||
enumiv%
|
||
\fi
|
||
\fi
|
||
\fi%
|
||
}
|
||
|
||
\ExplSyntaxOn
|
||
\cs_set:Nn \rawquestionandanswer:Nnnn {%
|
||
\ifstrequal{#2}{-}{}{\format_item:Nn #1{#2}} #3%
|
||
#4%
|
||
}
|
||
\cs_set:Nn \format_item:Nn {
|
||
\IfBlankTF{#2}{
|
||
\stepcounter{\getenum}
|
||
\item[#1{\csname the\getenum\endcsname}]
|
||
% 完美结合了LaTeX2e和LaTeX3!
|
||
}{
|
||
\item[#1{#2}]
|
||
}
|
||
}
|
||
\cs_set:Nn \Rformat:n {R#1.}
|
||
\cs_set:Nn \Pformat:n {P#1.}
|
||
\newcommand{\Rquestionandanswer}[3][]{%
|
||
\rawquestionandanswer:Nnnn \Rformat:n {#1}{#2}{#3}
|
||
}
|
||
\newcommand{\Pquestionandanswer}[3][]{%
|
||
\rawquestionandanswer:Nnnn \Pformat:n {#1}{#2}{#3}
|
||
}
|
||
\ExplSyntaxOff
|
||
\end{minted}
|
||
这里的\mintinline{latex}|\Rquestionandanswer|和\mintinline{latex}|\Pquestionandanswer|其实是使用了函数式程序设计中的部分参数调用的思想,对于原始的\mintinline{latex}|\rawquestionandanswer|,分别传入不同的格式,即\mintinline{latex}|\Rformat|和\mintinline{latex}|\Pformat|,形成了不同的函数,使用时直接调用\mintinline{latex}|\Rquestionandanswer|和\mintinline{latex}|\Pquestionandanswer|,传入剩余的参数,完成整个调用过程,这时之前定义的格式就会产生作用,形成不同的效果。
|
||
|
||
\subsection{总结}
|
||
从上面这些例子中,我们可以看到虽然函数式程序设计听起来比较陌生,但其实它的思想在很多编程语言中早已有所体现。还要注意的是,在一些情况其实也不是很适合使用函数式程序设计,比如具有大量可变状态的图形用户界面。了解函数式程序设计,也许在目前来看,并不能给我们带来什么实质性的帮助,但也许受到了函数式的思想潜移默化的影响,在未来的某一天,实现某个功能的时候,突然就有了灵感,想到一个绝妙的设计方案。函数式程序设计归根结底还是一种编程范式,了解各种编程范式,一定不能生搬硬套,要学会灵活使用,最终达成高内聚低耦合的目的。
|
||
|
||
\phantomsection\addcontentsline{toc}{subsection}{参考文献} %将参考文献放进目录
|
||
\bibliography{ref}
|
||
\ifSubfilesClassLoaded{
|
||
\stopcontents
|
||
}{}
|
||
\end{document} |