【问题标题】:non-technical benefits of having string-type immutable字符串类型不可变的非技术优势
【发布时间】:2011-04-04 20:07:07
【问题描述】:

我想知道从程序员的角度来看,字符串类型不可变的好处。

技术优势(在编译器/语言方面)可以概括为如果类型是不可变的,则更容易进行优化。阅读here 了解相关问题。

另外,在可变字符串类型中,要么你已经内置了线程安全(同样,优化更难做),要么你必须自己做。在任何情况下,您都可以选择使用具有内置线程安全性的可变字符串类型,因此这并不是不可变字符串类型的真正优势。 (同样,在不可变类型上进行处理和优化以确保线程安全会更容易,但这不是重点。)

但是不可变字符串类型在使用中的好处是什么?让某些类型不可变而其他类型不可变有什么意义?这对我来说似乎很不一致。

在 C++ 中,如果我想让一些字符串不可变,我将它作为 const 引用传递给函数 (const std::string&)。如果我想要原始字符串的可更改副本,我将其传递为std::string。只有当我想让它可变时,我才将它作为参考传递 (std::string&)。所以我只能选择我想做什么。我可以对所有可能的类型进行此操作。

在 Python 或 Java 中,有些类型是不可变的(大部分是基本类型和字符串),有些则不是。

在像 Haskell 这样的纯函数式语言中,一切都是不可变的。

是否有充分的理由说明这种不一致是有意义的?还是纯粹出于技术较低级别的原因?

【问题讨论】:

    标签: java c++ python string immutability


    【解决方案1】:

    程序员的主要优势是使用可变字符串,您无需担心谁会更改您的字符串。因此,您永远不必有意识地决定“我应该在这里复制这个字符串吗?”。

    【讨论】:

    • 然而,这并不是字符串或其他原始类型所特有的。通过这个论点,你会导致一切都是不可变的。这就是我的关键点:为什么字符串/其他原始类型是不可变的,而其余的则不是?
    • Albert:我的默认策略是让每个对象都不可变,除非它有充分的理由不可变。例如,在几何模型中,我会使 Point (x, y) 类不可变。这样,我就不必在使用 Point 的任何地方都执行 point.clone()。
    【解决方案2】:

    我不确定这是否属于非技术性的:如果字符串是可变的,那么大多数 (*) 集合需要对其字符串键进行私有副本。

    否则,将“foo”键从外部更改为“bar”会导致“bar”位于集合的内部结构中,而该集合的内部结构应该是“foo”。这样,“foo”查找将找到“bar”,这不是问题(不返回任何内容,重新索引有问题的键),但“bar”查找将找不到任何内容,这是一个更大的问题。

    (*) 在每次查找时对所有键进行线性扫描的哑集合不必这样做,因为它自然会适应键更改。

    【讨论】:

    • 通常,在 C++ 中,如果另一个对象(在本例中为哈希表)需要保留该字符串,则始终创建副本。在内部使用 copy-on-writestd::string 的 C++ STL 实现中,其行为与不可变字符串类型基本相同。
    • 另外,除此之外:您的论点并没有真正指出为什么要对字符串类型和原始类型这样做,而是为什么所有其他类型都是可变的。根据你的论点,让所有东西都不可变是有意义的。
    • @Albert:不,只有那些是集合中的键的东西。某些语言(Python)正是这样做的。
    【解决方案3】:

    吃点东西有什么意义 类型不可变而其他类型不可变?

    如果没有一些可变类型,您将不得不全力以赴进行纯函数式编程——与目前最流行的 OOP 和过程方法完全不同的范式,虽然非常功能强大,显然对许多程序员来说非常具有挑战性(当您确实需要在没有什么是可变的语言中产生副作用时会发生什么,而在实际编程中你当然不可避免地会这样做,这是挑战——例如,Haskell 的 Monads 是一种非常优雅的方法,但是您知道有多少程序员完全自信地理解它们并且可以使用它们以及典型的 OOP 构造?-)。

    如果您不了解提供多种范式的巨大价值(FP 之一 都非常依赖可变数据),我建议您学习 Haridi 和 Van Roy 的杰作,Concepts, Techniques, and Models of Computer Programming - - “21 世纪的SICP”,正如我曾经描述的那样;-)。

    大多数程序员,无论是否熟悉 Haridi 和 Van Roy,都会欣然承认至少有 一些 可变数据类型对他们来说很重要。尽管我从您的 Q 中引用了上面的句子,它采取了完全不同的观点,但我相信这也可能是您困惑的根源:不是“为什么每个”,而是“为什么一些不可变的”。

    “彻底可变”的方法曾经(偶然地)在 Fortran 实现中获得。如果你有,比如说,

      SUBROUTINE ZAP(I)
      I = 0
      RETURN
    

    然后一个程序sn-p在做,例如,

      PRINT 23
      ZAP(23)
      PRINT 23
    

    会打印 23,然后是 0 -- 数字 23 已经发生了变异,所以程序其余部分中对 23 的所有引用实际上都将引用 0。这不是编译器中的错误,从技术上讲:Fortran 在将常量与变量传递给分配给其参数的过程时,对您的程序是什么以及不允许做什么有一些微妙的规则,而这个 sn-p 违反了那些鲜为人知的、非编译器可执行的规则,所以它是a 但在程序中,不在编译器中。当然,在实践中,这种方式导致的错误数量高得令人无法接受,因此典型的编译器很快就会在这种情况下转向破坏性较小的行为(如果操作系统支持,将常量放在只读段中以获得运行时错误;或者, 传递一个新的 copy 常量而不是常量本身,尽管有开销;等等)即使在技术上它们是允许编译器非常“正确”地显示未定义行为的程序错误;-) .

    在其他一些语言中强制执行的替代方案是添加多种参数传递方式的复杂性——最明显的可能是在 C++ 中,按值、按引用、通过常量引用、通过指针、通过常量指针, ...然后你当然会看到程序员被 const foo* const bar 之类的声明所迷惑(如果 bar 是某个函数的参数,那么最右边的 const 基本上是无关紧要的......但如果 bar 是一个局部变量...!-).

    实际上 Algol-68 可能沿着这个方向走得更远(如果你可以有一个值和一个引用,为什么不引用一个引用?或者引用引用到引用?&c -- Algol 68 对此没有任何限制,并且定义正在发生的事情的规则可能是在“旨在实际使用”的编程语言中发现的最微妙、最难的组合)。早期的 C(只有按值和按显式指针——没有const,没有引用,没有复杂性)无疑是对它的部分反应,就像原始的 Pascal 一样。但是const 很快就进来了,并发症又开始增加了。

    Java 和 Python(以及其他语言)以强大的简单大砍刀穿过这个丛林:所有参数传递,所有赋值,都是“通过对象引用”(从不引用变量或其他参考,绝不是语义上的隐含副本,&c)。将(至少)数字定义为语义上不可变的数字可以避免像上面的 Fortran 代码那样出现“错误”,从而保持程序员的理智(以及语言简单这一宝贵方面)。

    将字符串视为原语就像数字一样与语言预期的高语义级别非常一致,因为在现实生活中我们确实需要像数字一样简单易用的字符串;诸如将字符串定义为字符列表 (Haskell) 或字符数组 (C) 等替代方法对编译器(在这种语义下保持高效性能)和程序员(有效地忽略这种任意结构以使字符串的使用变得简单)都提出了挑战原语,就像现实生活中的编程经常需要的那样)。

    Python 更进一步,添加了一个简单的不可变容器 (tuple) 并将 散列 绑定到“有效的不变性”(这避免了程序员在 Perl 中发现的某些意外情况) ,它的哈希允许可变字符串作为键)——为什么不呢?一旦你有了不变性(一个宝贵的概念,它使程序员不必学习 N 种不同的语义来进行赋值和参数传递,N 会随着时间的推移而增加;-),你不妨充分利用它;-) .

    【讨论】:

    • 嗨,Alex,你是最初为我提出这个问题的人。 :) 非常感谢您这么长的回答。我理解你的 Fortran 论点,但我并不认为它是有效的。它实际上取决于编译器的实现。你可以在 C++ 中做同样的事情,它可能在一些不起眼的编译器上产生相同的效果。 (我认为在您执行(int&)(const int&)(42) = 0; 之后这是未定义的行为。)我认为主要区别在于,正如您所说,对分配含义的不同解释。在 C++ 中是“覆盖对象内容”,在 Java/Py 中是“将 var 分配给不同的 obj”。
    【解决方案4】:

    如果字符串是可变的,那么字符串的许多消费者将不得不复制它。如果字符串是不可变的,那么这一点就不那么重要了(除非硬件互锁强制执行不变性,否则对于某些具有安全意识的字符串消费者来说,制作自己的副本可能不是一个坏主意,以防给定的字符串不是' t 应该是不可变的)。

    StringBuilder 类非常好,但我认为如果它具有“Value”属性会更好(读取将等同于 ToString,但它会显示在对象检查器中;写入将允许直接设置整个内容)和默认扩大转换为字符串。理论上,让 MutableString 类型从与 String 的共同祖先继承下来会很好,因此可以将可变字符串传递给不关心字符串是否可变的函数,尽管我怀疑依赖于事实的优化字符串有一定的固定实现会不太有效。

    【讨论】:

    • 是的,在你有可变字符串的语言中,如果你不希望它是可变的,你可以复制它或者将它作为常量引用传递。问题出在哪里?将某些内容作为副本传递实际上更少的代码(std::stringstd::string&)。
    • @Albert:Java 没有引用调用或引用 const 调用。 (除了 C++ 之外还有其他语言有“引用 const”的概念吗?)Java 让您在参数传递方面别无选择,因此 Java 的字符串对象必须是不可变的。
    • @Albert:每次将字符串传递给例程时都必须创建一个新的字符串副本,即使该例程对字符串所做的唯一事情是将其传递给其他例程,也会创建大量开销。
    • @supercat:你的意思是性能开销,对吧?我们又回到了技术方面。顺便说一句,在 C++ 中,这实际上是通过在内部执行一些 copy-on-write 魔术以高性能方式完成的(因此复制 std::string 不会复制内部数据——内部数据是仅在您尝试修改其中一个字符串时才真正复制,并且在此之前,它们保留对相同基础原始数据的引用)。而且,除此之外,在 C++ 中,您还可以传递 const 引用,即对字符串的不可变引用。
    • @Albert:我不相信任何std::string 实现使用COW。在多线程环境中,const 引用几乎不是不可变的。正如我在其他地方评论的那样,需要确定哪种类型的参数传递适合于不可变数据简化,这需要非技术开销。
    【解决方案5】:

    在具有用户定义类型的引用语义的语言中,拥有可变字符串将是一场灾难,因为每次分配字符串变量时,都会给可变字符串对象起别名,并且必须在整个过程中进行防御性复制这个地方。这就是为什么字符串在 Java 和 C# 中是不可变的——如果字符串对象是不可变的,那么有多少变量指向它并不重要。

    请注意,在 C++ 中,两个字符串变量从不共享状态(至少在概念上 -- 从技术上讲,可能会有 copy-on-write 发生,但由于效率低下,这种做法已经过时了在多线程场景中)。

    【讨论】:

    • 这就是为什么您可以决定是否要在 C++ 等语言中通过引用或按值传递变量。因此它是一个可变对象,您可以决定是否要传递可变引用或对象的副本或不可变的常量引用。
    【解决方案6】:

    如果您想要完全一致,您只能使一切不可变,因为可变的 Bool 或 Ints 根本没有任何意义。事实上,一些函数式语言就是这样做的。

    Python 的理念是“简单胜于复杂”。在 C 语言中,您需要意识到字符串会发生变化,并考虑这会如何影响您。 Python 假定字符串的默认用例是“将文本放在一起”——对于字符串,您绝对不需要知道任何事情来做到这一点。但是,如果您想要更改字符串,则只需使用更合适的类型(即列表、StringIO、模板等)。

    【讨论】:

    • 在 C++ 中,intbool 也是可变的。您可以将它们作为参考传递给其他所有内容。所以让所有东西都可变不是问题。但是您遇到了我的主要问题:让一些原始类型不可变而其余类型可变有什么意义?
    • @Albert: int 变量 是可变的,但int 是不可变的。 i可以改,5不能改。
    • 在 C++ 中,“int”、“double”或“bool”是不可变的。当使用整数值 42 调用函数时,调用者传递位模式 0x0000002A,作为整数,//总是// 表示 42。这与 FORTRAN 不同,在 FORTRAN 中,被调用函数将接收一个位模式,该位模式指向一个内存区域,该区域有望包含数字 42。如果被调用函数更改了存储在那里的值,则使用常量 42 的某些或所有其他位置也会改变。
    • 我认为,从技术上讲,这取决于编译器。可能没有 C 编译器会将整数放在其他地方并引用它,因为它可以直接将它放入机器指令中。但是在不是这种情况的架构上,实际上可能会改变它。如果可执行文件设法在运行时以某种方式覆盖该内存,则对于可执行文件的字符串池也是如此。
    【解决方案7】:

    不确定您是否会将其视为“技术低级”的好处,但不可变字符串隐含地是线程安全的这一事实为您节省了大量的线程安全编码工作。

    略带玩具的例子……

    线程 A - 检查登录名为 FOO 的用户是否有权做某事,返回 true

    线程 B - 修改用户字符串为登录名 BAR

    线程 A - 由于之前通过 FOO 的权限检查,使用登录名 BAR 执行一些操作。

    String 无法更改的事实为您节省了防范这种情况的工作。

    【讨论】:

    • 是的,但是您可以只拥有一个线程安全的可变字符串类型,然后就可以使用它。从程序员的观点来看,线程安全并不是一个很好的观点,为什么你更喜欢不可变类型而不是线程安全的可变类型。
    • 这不仅仅是关于 String 类型的线程安全。在上面的示例中,您必须围绕检查块进行同步,然后在 String 处于共享状态时采取行动,即使它是线程安全的可变状态。不变性意味着您不必这样做。再次,一个玩具例子,但一个真实的:)
    • 在上面的示例中,您将只在线程中使用字符串的副本,因为您不希望它在另一个线程中可变。在 C++ 中,您将隐式获取副本,具体取决于您的实现方式。如果您的观点是不变性通常更好,那么为什么 Java/Python 中的其他类型是可变的呢?
    • 没有真正的开销。如果您在 C++ 中执行std::string a = b;,则您已经有了一个副本,而不是参考。事实上,作为引用传递是要输入一个字符,即std::string& a = b;
    • 开销是试图记住是否需要制作副本。
    【解决方案8】:

    没有首要的、根本的理由不让字符串可变。我为它们的不变性找到的最好解释是,它促进了一种功能更强大、副作用更少的编程方式。这最终变得更干净、更优雅、更 Pythonic。

    从语义上讲,它们应该是不可变的,不是吗?字符串"hello" 应始终表示"hello"。你不能改变它,就像你不能改变数字三一样!

    【讨论】:

    • 字符串"hello"在像C++这样的语言中是常​​量,所以不能改变。
    • @Albert:它只是在“尝试修改字符串文字的效果未定义”中保持不变。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2013-01-08
    • 1970-01-01
    • 2013-08-15
    • 1970-01-01
    • 2013-09-13
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多