tl; dr
几天前,我遇到了一篇文章,上面有一个有趣的标题。
"Clean" Code, Horrible Performance
哇,这个标题肯定别无选择,只能单击并了解更多!
我立即深入研究了文章,并总结其内容,讨论了如何忽略清洁代码的某些关键原则实际上导致代码性能的改进范围从1.5倍到超过10倍以上,最终得出了与标题建议的相同结论。
但是,本文的论点有一些逻辑错误。其中之一是,即使基准测试的速度快10倍,也不一定意味着整个程序的性能将快10倍。
例如,查看此benchmark,使用JavaScript中的功能可能比使用类的速度快8倍,但是在大多数程序中,我们几乎不会注意到这两种方法之间的区别。
此外,现代开发人员倾向于将开发成本优先于一定水平的绩效和维护成本而不是开发成本,这是许多人认为有效的观点。
但是,并非所有的范式都涉及性能和开发人员经验之间的权衡,在本文中,该论点认为功能编程不仅会阻碍性能,而且会对开发人员的体验产生负面影响。
管道和性能
使用纯粹的功能和创建管道的不变性确实是功能编程的本质,也是其基础的核心原则。
然而,矛盾的是,功能编程在使用管道时可以引入重要的开销。
让我们看以下示例。
function pipe(...funcs) {
return function(data) {
for (const func of funcs) {
data = func(data)
}
return data
}
}
const data = { ...somethings }
const new_data = pipe(
function_1,
function_2,
function_3,
function_4,
function_5
)(data)
在上面的示例中,function_n
是一个虚构的纯函数。
该函数在管道中称为5次的事实意味着数据复制了5次。
这是因为对象复制是一个非常昂贵的操作,这就是为什么JavaScript,Rust和其他现代编程语言将参考使用对象分配。
那么,我们如何解决这个问题?
实际上,我们不必担心。
我们需要做的就是将deep_copy
添加到管道的开头。
. . . . . .
const data = { ...somethings }
const new_data = pipe(
deep_copy,
function_1,
function_2,
function_3,
function_4,
function_5
)(data)
但是,该解决方案要求function_n
可变,这违反了不变性的功能编程原则。
实施计数器
接下来,让我们使用FP(功能编程,OOP(面向对象的编程)和ECS(实体组件系统)方法实现计数器功能。
fp
function increment(num) {
return num + 1
}
const counter = {
_count: 0,
get_count() {
return this._count
},
set_count(count) {
this._count = count
}
}
button.onclick = pipe(
counter.get_count,
increment,
counter.set_count
)
为避免副作用,我们将increment
函数分离为外部纯函数,并且计数被getters and steters更改。
实际上,由于几个原因,即使上述代码也不完全符合FP。
哎呀
const counter = {
_count: 0,
get_count() {
return this._count
},
increment() {
this._count++
}
}
button.onclick = counter.increment
JS
实际上,上述代码并不完全符合OOP。
但是,我们可以从上面的代码中获得的想法是,我们可以通过限制对象内部的函数的副作用来提高代码的可维护性。
ECS
const counter = { count: 0 }
/** @type {Record<string, (entity: { count: number }) => *>} */
const CounterSystem = {
increment(entity) {
entity.count++
}
}
button.onclick = () => CounterSystem.increment(counter)
ecs是一种分离数据和功能的方式,简单地说。
这是一种非常有趣的方法,但在这里我只会提到它是一个简单的比较。
使用三种方法中每种方法的开发经验是什么?
FP绝对比其他两种方法慢。
但是,FP也具有明确的优势,这是通过声明性编程的出色可维护性。
另外,执行异步并行任务时没有限制,因为没有副作用。
但是,这只是FP训练营的主张,如果您查看上面的示例代码,FP似乎并不比其他两种方法更直观,也似乎更容易维护。
因此,我们需要问的问题是,声明性编程是否比程序编程更可维护吗?
线程安全是否在并行处理功能编程的独家域?
快速平方根
FAST INVSQRT是一个函数,可以通过利用在内存中表示浮点数的方式来快速计算反平方根。
这是一种非常著名的算法,该算法在1999年发行的游戏地震III竞技场中使用。
float Q_rsqrt( float number )
{
long i;
float x2, y;
const float threehalfs = 1.5F;
x2 = number * 0.5F;
y = number;
i = * ( long * ) &y; // evil floating point bit level hacking
i = 0x5f3759df - ( i >> 1 ); // what the fuck?
y = * ( float * ) &i;
y = y * ( threehalfs - ( x2 * y * y ) ); // 1st iteration
// y = y * ( threehalfs - ( x2 * y * y ) ); // 2nd iteration, this can be removed
return y;
}
此代码不长,但我不明白它是如何工作的。
但这没关系。
我只需要知道Q_rsqrt(number)
返回了1 / sqrt(number)
的近似值。
同样很明显,需要修改Q_rsqrt
以支持更快,更准确的内置CPU操作RSQRTSS
。
在编程中,功能通常是功能或修改的单位。
功能的名称用作路线图,总结了功能中的逻辑并允许了解整体流程。
我个人认为,在编码约定和测试代码中,投入正确实施功能编程所需的技能和努力是编写更高质量程序的方法。
不变性的陷阱
不变性阻止副作用并使程序结构简单。
这真的是真的吗?
让我们以简单的方式比较React和Svelte的代码
export function App() {
const [count, set_count] = useState([0, 2])
function handle_click() {
count[0] += count[1]
set_count(count)
}
return (
<button onClick={handle_click}>
Clicked {count[0]}
{count[0] <= 1 ? " time" : " times"}
</button>
)
}
<script>
let count = [ 0, 2 ]
function handle_click() {
count[0] += count[1]
}
</script>
<button on:click={handle_click}>
Clicked {count[0]}
{count[0] <= 1 ? "time" : "times"}
</button>
是的,我已经实现了一个简单的计数器功能和svelte。
但是,实际上,上面示例中的反应代码无法正常工作。
要使上述代码正常工作,我们需要按以下方式修改handle_click
函数。
function handle_click() {
count[0] += count[1]
// set_count(count)
set_count([...count])
}
原因是React需要提供一个新的对象来通知状态更改。
React is a framework that accounts for 82% of the front-end usage as of State of JS 2022.
React的不变性限制由于其高市场份额而被转变为清洁代码的条件。
今天的前端框架使我们能够声明地写组件。
即使我们在多个屏幕上使用相同的数据,渲染也不会修改数据,因此这不是问题。如果数据更改,则意味着需要更改视图。
但是,在React中,我们需要在状态更改时复制对象,这只是纯开销。
结论
限制副作用并简化代码流是改善开发人员体验的重要元素,例如程序维护。
但是,我个人认为,从开发人员体验的角度来看,功能编程是一种误导的方法,因为它不仅会严重损害代码性能,而且会损害代码流。
请注意,本文中的批评仅限于极端功能编程。
JavaScript是一种多范式编程语言,足够灵活地包含了多个范式的好处。
谢谢。