用数学思考,用代码创作

编程语言是用于向机器发指令的实现工具,而不是用于表达想法的思考工具。 它们是严格的正式系统,充满了设计上的权衡和实际限制。

用数学思考,用代码创作
一键发币: x402兼容 | Aptos | X Layer | SUI | SOL | BNB | ETH | BASE | ARB | OP | Polygon | Avalanche

程序员喜欢讨论编程语言。 我们不仅争论它们的技术优点和美学质量, 而且它们已成为我们个人身份的一部分, 以及我们所关联的价值观和特质。 有些人甚至捍卫一种语言决定论的观点,认为思维被语言的可输入性所限制。

由于我们花了很多时间编写代码,对语言设计的兴趣是合理的。 然而,这些讨论的性质表明我们将其视为更多东西, 可能已经忘记了它们的主要作用。 编程语言是用于向机器发指令的实现工具,而不是用于表达想法的思考工具。 它们是严格的正式系统,充满了设计上的权衡和实际限制。 归根结底,我们希望它们让人类控制计算机变得可以忍受。 相比之下,思想最好通过一个自由和灵活的媒介来表达。

1、用数学思考

几千年来,用于思考计算的自然语言是数学。 大多数人不认为数学是自由或灵活的。 他们想到的是可怕的符号和记忆步骤以便在考试中背诵。 其他人听到数学就想到范畴理论、λ演算或其他形式化计算的方法,但这些对于编程本身来说几乎不需要。

我希望这篇文章的读者对数学是什么有更积极的经验,比如图论、算法或线性代数课程; 那种涉及逻辑和定理,并用散文和符号混合写成的课程(大多数符号直到16世纪才被发明)。 这种数学是关于通过仔细的定义和推导来创建逻辑模型以理解现实世界问题。 如果你不清楚这是什么样子,我推荐TrudeauStepanovManber

数学允许你推理逻辑结构,不受其他约束。 这也是编程所需要的:创建逻辑系统来解决问题。 看看编程的基本模式:

  1. 识别一个问题
  2. 设计算法和数据结构来解决它
  3. 实现并测试它们

实际上,工作并不是那么有条理,因为步骤之间有相互作用。 你可能会写代码来影响设计。 即使如此,基本模式还是反复使用。

注意步骤1和2是花费我们最多时间、能力和精力的部分。 同时,这些步骤并不适合编程语言。 这并没有阻止程序员试图在编辑器中解决它们,但最终得到的代码会混乱、缓慢或者解决错误的问题。 不是编程语言还不够好。 而是没有任何形式语言能很好地做到这一点。 我们的大脑就是不能这样思考。 当问题变得困难时,我们会画图并与同事讨论。

理想情况下,步骤1和2首先被解决,然后才会使用编程语言来解决步骤3。 这样做还有一个额外的好处,即改变实现过程。 有了数学解决方案,你可以专注于选择最佳表示和实现,并写出更好的代码,知道最终目标是什么。

2、实现的关注点

为什么编程语言是令人烦恼的思考工具? 一个原因是编写代码不可避免地与实现关注点联系在一起。 一台计算机必须管理各种任务,同时受到物理和经济限制。 想想写一个简单函数的所有考虑因素:

  • 我应该提供哪些输入?
  • 它们应该被命名为什么?
  • 它们应该是什么类型?(即使是动态类型语言也必须考虑类型,只是它是隐式的。)
  • 我应该按值还是引用传递它们?
  • 我应该把函数放在哪个文件中?
  • 结果是否应该重用,还是每次重新计算都足够快?

这个列表可以继续下去。关键是这些考虑与函数做什么无关。 它们分散了对函数试图解决的问题的注意力。

许多语言旨在隐藏这些细节,这在处理日常任务时是有帮助的。 然而,它们无法超越其作为实现工具的角色。 SQL 是这方面最成功的例子之一,但它最终仍然关注实现关注点,如表、行、索引和类型。 因此,程序员仍然以非正式的方式设计复杂的查询,比如他们想要“获取”什么,然后再写一堆 JOIN

3、不灵活的抽象

编程语言的另一个限制是它们是糟糕的抽象工具。 通常,当我们讨论工程中的抽象时,我们指的是隐藏实现细节。 一个复杂的操作或过程被封装成一个“黑箱”,其中内容被隐藏,暴露了明确的输入和输出。 伴随着盒子的是一个虚构的故事,以大大简化的方式解释它做了什么。

黑箱对于构建大型系统至关重要,因为细节太繁重,无法全部记住。 它们也有许多众所周知的局限性。 黑箱会泄漏,因为简短的描述无法完全确定其行为。 不透明的接口引入了低效率,如重复和碎片化的设计。

最重要的是,对于解决问题而言,黑箱是僵硬的。 它们必须显式地揭示一些旋钮和开关,并隐藏其他部分, 承诺于一个特定的视角,说明什么对用户是重要的,什么是噪音。 这样做时,它们呈现了一个固定的抽象级别,这可能对问题来说太高或太低。 例如,一个高级的网络服务器可能为服务 JSON 提供极好的接口,但如果有人想为服务不完整的数据流(如程序的输出)提供接口,则毫无用处。 理论上,你可以随时查看盒子内部,但在代码中,抽象级别在任何时候都是固定的。

相比之下,数学中的抽象一词与隐藏信息完全不同。 在这里,抽象意味着根据特定上下文提取某物的本质特征或特性。 不像黑箱,没有信息被隐藏。 它们不会以同样的方式泄漏。 你被鼓励调整到合适的抽象层次,并快速在不同视角之间切换。 你可能会问:

  • 这个问题最好用表格表示吗?或者,用函数?
  • 我可以把整个系统看作一个函数吗?
  • 我可以把这一组事物当作一个单元吗?
  • 我应该看整个系统还是单个部分?
  • 我应该做出哪些假设?应该加强还是削弱它们?

看看函数的多种表示方式:

用数学思考允许你在任何时刻使用带来最大清晰度的方式。

事实证明,大多数抽象概念可以从许多视角来理解,就像函数一样。 学习数学为你提供了研究各种问题的多功能工具箱。 你可能首先用公式描述一个问题,然后转而用几何方式理解它, 然后认识到某些群论(抽象代数)正在发挥作用, 所有这一切结合起来带来了洞察力和理解。

总之,编程语言是组装黑箱的优秀工程工具; 它们提供了函数、类和模块,所有这些都有助于将代码包装成良好的接口。 然而,当尝试解决问题和设计解决方案时,你真正需要的是数学意义上的抽象。 如果你试图在键盘上思考,可用的黑箱会扭曲你的观点。

4、问题的表示

正如编程语言在抽象能力上是僵硬的一样,它们在数据表示方面也是僵硬的。 实施算法或数据结构的行为本身就是选择仅有一种众多可能的表示方式; 以及随之而来的所有权衡。 当你有使用案例并且了解问题时,做出权衡总是更容易。

例如,图(顶点和边的集合)出现在许多编程问题中,如互联网网络、路径查找和社交网络。 尽管它们的定义很简单,但选择如何表示它们却很难,并且根据使用案例而有很大差异:

  • 最接近定义的一种: vertices: vector<NodeData> edges: vector<pair<Int, Int>> (如果你只关心连通性,顶点可以被删除。)
  • 如果你想快速遍历一个节点的邻居,那么你可能想要一个节点结构: Node { id: Int, neighbors: vector<Node*> }
  • 你可以使用邻接矩阵。每一行存储特定节点的邻居: connectivity: vector<vector<int>> 节点本身是隐式的。
  • 路径查找算法经常从单元格板中隐式地处理图: walls: vector<vector<bool>>.
  • 在点对点网络中,每台计算机是一个顶点,每个套接字是一条边。 整个图甚至无法从一台机器访问!

数学允许你推理图本身,解决问题,然后选择适当的表示。 如果你用编程语言思考,你就不能延迟这个决定,因为你的第一行代码就决定了特定的表示。

请注意,图的表示方式太多,无法被多态接口封装。 (再考虑一下代表计算机网络的图,比如整个互联网。) 所以创建一个完全可重用的库是不现实的。 它只能适用于几种类型,或者迫使所有图进入不合适的表示。 这并不意味着库或接口没有用。 类似的表示方式一再需要(比如 std::vector), 但你不能编写一个库来一次性封装“图”的概念。 一个简单的泛型或带有几种类型的接口是合适的。

作为推论,编程语言应主要专注于作为有用的实现工具, 而不是理论工具。 现代语言功能的一个良好示例是 async/await。 它没有隐藏复杂的细节或引入新的概念理论。 它解决了常见的实际问题,并使编写更容易。

用数学思考也使“C风格”的编程更具吸引力。 当你充分理解一个问题时,就不必为了“如果...会怎样”而建立框架和抽象层。 你可以为问题量身定制程序,做出精心选择的权衡。

5、示例项目

那么用数学思考是什么样的呢? 在本节中,你可能需要读得更慢、更仔细一些。 最近,我在工作中参与了一个为商家定价加密货币的API。 它考虑到最近的价格变化,并建议商家在波动时期收取更高的价格。

虽然我们做了一些理论研究,但我们想通过实证测试来查看它在各种市场条件下的表现。 为此,我设计了一个机器人来模拟一个使用我们API的商家,看看它的表现如何。

BTC/USD (1天)

前提

定义: 汇率 r(t)fiat/crypto 的市场率。

定义: 商家汇率 r'(t) 是商家建议客户支付的修改后的汇率。

定义: 当顾客购买商品时,我们称该事件为购买。 购买包括以法币计价和时间。 p = (f, t).

定理: 购买所需的加密货币数量由应用修改后的汇率得出 t(p) = p(1) / r'(p(2)).

证明: p(1) / r'(p(2)) = fiat / (fiat/crypto) = fiat * crypto/fiat = crypto

定义: 当商家出售他们的加密货币储备时,我们称该事件为销售。 销售包括加密货币的数量和时间戳。 s = (c, t).

定理: 商家从销售中获得的法币金额是通过应用汇率到销售 g(s) = s(1) * r(s(2)) 得出的。

证明: s(1) * r(s(2)) = crypto * (fiat/crypto) = fiat

定义: 余额 是一组购买和销售之间的差额,即所有购买的加密货币数量和所有销售的加密货币数量之差。 b(P, S) = sum from i to N of t(p_i) - sum from j to M of s_j(1)

请注意,b(P, S) >= 0 必须始终成立。

定义: 收益 是一组购买和销售之间的差额,即所有销售的法币金额和所有购买的法币金额之差。 e(P, S) = sum from j to M of g(s_j(1)) - sum from i to N of p_i(1) >= 0.

目标

定义: 我们说商家汇率是有利的,如果对大多数典型购买和销售集,收益是非负的。 r'(t) is favorable iff e(P, S) >= 0.

在有利的情况下,商家接受加密货币没有损失任何法币。

mosttypical 将不会被严格定义。

作为 typical 的一部分,我们可以假设商家会及时出售他们的加密货币。 因此假设 s_i(2) - s_j(2) < W 对于 i,j in {1.. M} 有一些界限 W。 购买金额应在商业进行的合理范围内随机分布。也许 $10-100。

机器人的目标是验证 r'(t) 是有利的。

请注意,这个定义只是一个质量衡量标准。 也许保护自己免受最坏情况的影响比有利更重要。 在这种情况下,我们将关注构造一个具有非常负面收益的购买集的能力。

算法

重复多次:

  1. 随机选择一个时间范围 [t0, t1]
  2. [t0, t1] 内随机选择一组购买。 价格应落在典型的 [p0, p1] 价格范围内。
  3. [t0, t1] 内均匀间隔的时间(可能带有一点随机噪声)生成一组销售。 每次销售应为该时间点的完整余额
  4. 计算这些集合的收益
  5. 记录收益。

之后:

  1. 报告有多少收益是负的和非负的。显示每种的百分比。
  2. 确定最小和最大收益并报告它们。

6、结束语

当你阅读这个例子时,我猜你可能会觉得这些陈述是显而易见的。 当然,这些步骤都不难。 然而,令我惊讶的是,我的许多假设被纠正了,选择一个有利结果的客观定义是多么困难。 这个过程帮助我意识到,如果我一开始只是写代码,我不会考虑这些假设。 也许最大的好处是,在写完之后,我能够快速与同事一起回顾并进行容易在纸上修改的修正,但在代码中则难以更改。


原文链接:Think in Math. Write in Code.

DefiPlot翻译整理,转载请标明出处

免责声明:本站资源仅用于学习目的,也不应被视为投资建议,读者在采取任何行动之前应自行研究并对自己的决定承担全部责任。