拥有字符串类型不可变的非技术性好处
我在想,从程序员的角度来看,字符串类型不可变有什么好处。
从技术上讲(比如编译器或语言的角度),不可变类型的主要好处是更容易进行优化。如果你想了解更多,可以看看这里的相关问题。
另外,如果字符串是可变的,要么你已经内置了线程安全(但这样优化就会变得更难),要么你得自己去处理。无论如何,你可以选择使用一个内置线程安全的可变字符串类型,所以这并不是不可变字符串类型的优势。(不过,确保不可变类型的线程安全会更容易处理和优化,但这不是重点。)
那么,在使用上,不可变字符串类型有什么好处呢?为什么有些类型是不可变的,有些则不是?这看起来很不一致。
在C++中,如果我想让某个字符串不可变,我会把它作为常量引用传递给一个函数(const std::string&
)。如果我想要原始字符串的一个可变副本,我就会传递它作为std::string
。只有当我想让它可变时,我才会传递它作为引用(std::string&
)。所以我只是选择我想做的事情。我可以对每种可能的类型都这样做。
在Python或Java中,有些类型是不可变的(大多数原始类型和字符串),而有些则不是。
在像Haskell这样的纯函数式语言中,一切都是不可变的。
有没有什么好的理由说明为什么这种不一致是有意义的?还是说这纯粹是出于技术上的低层原因?
8 个回答
如果你想要完全的一致性,那你只能让所有东西都不可变,因为可变的布尔值或整数根本没有意义。实际上,一些函数式编程语言就是这样做的。
Python的理念是“简单比复杂好”。在C语言中,你需要注意字符串是可以改变的,并考虑这会对你产生什么影响。而在Python中,默认情况下,字符串的使用场景是“把文本拼在一起”——为了做到这一点,你根本不需要了解字符串的任何特别知识。不过,如果你想要让你的字符串发生变化,你只需要使用更合适的类型(比如列表、StringIO、模板等)。
我不确定这算不算非技术性的问题,不过我来解释一下:如果字符串是可变的,那么大多数集合需要对它们的字符串键做私有的副本。
否则,如果一个叫“foo”的键在外部被改成了“bar”,那么在集合内部就会出现“bar”,而原本应该是“foo”的地方。这就导致了一个问题:当你查找“foo”时,会找到“bar”,这虽然不是太大的问题(可以返回空值,重新索引这个有问题的键),但如果你查找“bar”时却什么都找不到,那就麻烦大了。
(*) 有些简单的集合在每次查找时都会线性扫描所有键,这种情况下就不需要做副本,因为它们自然能处理键的变化。
为什么有些类型是不可变的,有些则不是呢?
如果没有一些可变类型,你就得完全转向纯函数式编程——这是一种与现在流行的面向对象和过程式编程完全不同的思维方式。虽然这种方式非常强大,但对很多程序员来说,似乎也很难掌握(在一个没有可变数据的语言中,当你确实需要副作用时会发生什么,这就是一个挑战——比如Haskell的单子就是一种非常优雅的解决方案,但你认识的程序员中有多少人能完全理解并自信地使用它们呢?)。
如果你还不明白拥有多种编程范式(既有函数式编程,也有依赖可变数据的范式)的巨大价值,我建议你去看看Haridi和Van Roy的经典著作《计算机编程的概念、技术和模型》——我曾把它称为“21世纪的SICP”。
大多数程序员,无论是否熟悉Haridi和Van Roy,都会承认至少有一些可变数据类型对他们来说是重要的。尽管你提问时引用的那句话有着完全不同的观点,我认为这也可能是你困惑的根源:不是“为什么有些是可变的”,而是“为什么会有一些不可变的”。
曾经在Fortran的实现中,出现过一种“完全可变”的方式。如果你有,比如说:
SUBROUTINE ZAP(I)
I = 0
RETURN
那么一段程序代码,比如:
PRINT 23
ZAP(23)
PRINT 23
会先打印23,然后打印0——因为数字23被改变了,所以程序中所有对23的引用实际上都指向0。这并不是编译器的错误:Fortran对常量和变量传递给过程时的规则有一些微妙的限制,而这段代码违反了这些不为人知的、无法由编译器强制执行的规则,因此这是程序中的一个错误,而不是编译器的问题。实际上,这种方式导致的错误数量是不可接受的,所以典型的编译器很快就转向了在这种情况下更不具破坏性的行为(如果操作系统支持的话,将常量放在只读段中以产生运行时错误;或者,传递常量的一个新副本而不是常量本身,尽管这样会增加开销;等等),尽管从技术上讲,它们是允许编译器显示未定义行为的程序错误。
一些其他语言则通过增加多种参数传递方式来解决这个问题——尤其是在C++中,可能有按值、按引用、按常量引用、按指针、按常量指针等等,然后你会看到程序员对像const foo* const bar
这样的声明感到困惑(右边的const
在bar
作为某个函数的参数时基本上是无关紧要的……但如果bar
是一个局部变量,那就至关重要了!)。
实际上,Algol-68可能在这方面走得更远(如果你可以有值和引用,为什么不可以有引用的引用?或者引用的引用的引用?等等——Algol 68对此没有限制,定义这些规则的方式可能是“为实际使用而设计”的编程语言中最微妙、最复杂的混合)。早期的C(只有按值和按显式指针——没有const
、没有引用、没有复杂性)无疑部分是对此的反应,最初的Pascal也是如此。但const
很快又出现了,复杂性再次增加。
Java和Python(以及其他一些语言)用简单的方式解决了这个问题:所有参数传递和所有赋值都是“按对象引用”(从不引用变量或其他引用,从不隐式复制等)。将(至少)数字定义为语义上不可变的,保持了程序员的理智(以及语言简单性的这个宝贵方面),避免了像上面Fortran代码那样的“糟糕情况”。
将字符串视为与数字一样的基本类型,与这些语言的高语义水平是一致的,因为在现实生活中,我们确实需要像使用数字一样简单使用字符串;将字符串定义为字符列表(Haskell)或字符数组(C)会给编译器(在这种语义下保持高效性能)和程序员(有效地忽略这种任意结构,以便在实际编程中将字符串作为简单的基本类型使用)带来挑战。
Python进一步通过添加一个简单的不可变容器(tuple
)并将哈希与“有效不可变性”联系起来(这避免了程序员在使用可变字符串作为键的哈希时可能遇到的某些意外情况,比如在Perl中)——为什么不呢?一旦你有了不可变性(这是一个珍贵的概念,可以让程序员不必学习N种不同的赋值和参数传递语义,而N往往随着时间的推移而增加),你就可以充分利用它。