簡體   English   中英

為什么可變結構是“邪惡的”?

[英]Why are mutable structs “evil”?

在 SO 上的討論之后,我已經多次閱讀了關於可變結構是“邪惡的”的評論(就像在這個問題的答案中一樣)。

C# 中可變性和結構的實際問題是什么?

結構是值類型,這意味着它們在傳遞時會被復制。

因此,如果您更改副本,您只會更改該副本,而不是原件,也不會更改可能存在的任何其他副本。

如果您的結構是不可變的,那么所有通過值傳遞產生的自動副本都將是相同的。

如果你想改變它,你必須有意識地通過使用修改后的數據創建結構的新實例來進行。 (不是副本)

從哪里開始;-p

Eric Lippert 的博客總是很適合引用:

這是可變值類型是邪惡的另一個原因。 嘗試始終使值類型不可變。

首先,您往往很容易丟失更改……例如,從列表中取出內容:

Foo foo = list[0];
foo.Name = "abc";

那有什么改變? 沒什么用...

與屬性相同:

myObj.SomeProperty.Size = 22; // the compiler spots this one

強迫你做:

Bar bar = myObj.SomeProperty;
bar.Size = 22;
myObj.SomeProperty = bar;

不太重要的是,存在尺寸問題; 可變對象往往具有多個屬性; 但是,如果您有一個包含兩個int 、一個string 、一個DateTime和一個bool ,您會很快消耗大量內存。 對於一個類,多個調用者可以共享對同一個實例的引用(引用很小)。

我不會說邪惡,但可變性通常是程序員過度渴望提供最大功能的標志。 實際上,這通常是不需要的,這反過來會使界面更小、更易於使用且更難使用錯誤(= 更健壯)。

一個例子是競爭條件下的讀/寫和寫/寫沖突。 這些根本不能出現在不可變結構中,因為寫入不是有效的操作。

另外,我聲稱幾乎從來不需要可變性,程序員只是認為可能在未來。 例如,更改日期根本沒有意義。 相反,根據舊日期創建一個新日期。 這是一個廉價的操作,所以性能不是一個考慮因素。

可變結構並不邪惡。

它們在高性能環境中是絕對必要的。 例如,當緩存行和/或垃圾收集成為瓶頸時。

我不會將在這些完全有效的用例中使用不可變結構稱為“邪惡”。

我可以看到,C#的語法不利於區分值類型或引用類型的成員的接入點,所以我所有喜歡一成不變的結構,即強制執行不變性,在可變的結構。

然而,我建議不要簡單地將不可變結構標記為“邪惡”,而是建議接受該語言並提倡更有幫助和建設性的經驗法則。

例如: “結構是值類型,默認情況下被復制。如果你不想復制它們,你需要一個引用”“嘗試首先使用只讀結構”

具有公共可變字段或屬性的結構並不是邪惡的。

改變“this”的結構方法(與屬性設置器不同)有點邪惡,只是因為.net沒有提供將它們與沒有的方法區分開來的方法。 不改變“this”的結構方法即使在只讀結構上也應該是可調用的,而無需任何防御性復制。 改變“this”的方法根本不應該在只讀結構上調用。 由於 .net 不想禁止不修改“this”的結構方法在只讀結構上被調用,但不想允許只讀結構發生變異,因此它防御性地復制只讀結構只有上下文,可以說是兩全其美。

盡管在只讀上下文中處理自變異方法存在問題,但是,可變結構通常提供遠優於可變類類型的語義。 考慮以下三個方法簽名:

struct PointyStruct {public int x,y,z;};
class PointyClass {public int x,y,z;};

void Method1(PointyStruct foo);
void Method2(ref PointyStruct foo);
void Method3(PointyClass foo);

對於每種方法,請回答以下問題:

  1. 假設該方法不使用任何“不安全”代碼,它是否會修改 foo?
  2. 如果在調用該方法之前不存在對 'foo' 的外部引用,那么在調用方法之后是否會存在外部引用?

答案:

問題 1:
Method1() :否(意圖明確)
Method2() : 是(意圖明確)
Method3() : 是(不確定意圖)
問題2:
Method1() : 沒有
Method2() :否(除非不安全)
Method3() : 是

Method1 不能修改 foo,並且永遠不會得到引用。 Method2 獲得對 foo 的短期引用,它可以使用它以任何順序修改 foo 的字段任意次數,直到它返回,但它不能保留該引用。 在 Method2 返回之前,除非它使用不安全的代碼,否則任何和所有可能由其 'foo' 引用制成的副本都將消失。 與 Method2 不同的是,Method3 獲得了一個對 foo 的可混雜共享的引用,並且不知道它可以用它做什么。 它可能根本不會改變 foo,它可能會改變 foo 然后返回,或者它可能會將 foo 的引用提供給另一個線程,該線程可能會在未來某個任意時間以某種任意方式改變它。 限制 Method3 可能對傳遞給它的可變類對象執行的操作的唯一方法是將可變對象封裝到只讀包裝器中,這既丑陋又麻煩。

結構數組提供了美妙的語義。 給定 Rectangle 類型的 RectArray[500],很明顯如何例如將元素 123 復制到元素 456,然后在一段時間后將元素 123 的寬度設置為 555,而不會干擾元素 456。“RectArray[432] = RectArray[321 ]; ...; RectArray[123].Width = 555;". 知道 Rectangle 是一個結構體,它有一個名為 Width 的整數字段,這將告訴人們所有需要了解的上述語句。

現在假設 RectClass 是一個與 Rectangle 具有相同字段的類,並且想要對 RectClass 類型的 RectClassArray[500] 執行相同的操作。 也許該數組應該保存 500 個對可變 RectClass 對象的預初始化的不可變引用。 在這種情況下,正確的代碼將類似於“RectClassArray[321].SetBounds(RectClassArray[456]); ...; RectClassArray[321].X = 555;”。 也許假設數組包含不會改變的實例,所以正確的代碼更像是“RectClassArray[321] = RectClassArray[456]; ...; RectClassArray[321] = New RectClass(RectClassArray[321] ]); RectClassArray[321].X = 555;" 要知道一個人應該做什么,就必須了解更多關於 RectClass(例如,它是否支持復制構造函數、復制自方法等)和數組的預期用途。 遠不如使用結構那么干凈。

可以肯定的是,不幸的是,除了數組之外的任何容器類都沒有很好的方法來提供結構數組的清晰語義。 最好的方法是,如果想要使用例如字符串對集合進行索引,則可能是提供一個通用的“ActOnItem”方法,該方法將接受一個字符串作為索引、一個通用參數和一個將被傳遞的委托通過引用泛型參數和集合項。 這將允許與 struct 數組幾乎相同的語義,但除非 vb.net 和 C# 人員可以提供良好的語法,否則即使代碼具有合理的性能(傳遞泛型參數會允許使用靜態委托,並且無需創建任何臨時類實例)。

就個人而言,我對 Eric Lippert 等人的仇恨感到惱火。 關於可變值類型的噴涌。 與到處使用的混雜引用類型相比,它們提供了更清晰的語義。 盡管 .net 對值類型的支持存在一些限制,但在許多情況下,可變值類型比任何其他類型的實體都更適合。

從程序員的角度來看,還有一些其他極端情況可能會導致不可預測的行為。

不可變值類型和只讀字段

    // Simple mutable structure. 
    // Method IncrementI mutates current state.
    struct Mutable
    {
        public Mutable(int i) : this() 
        {
            I = i;
        }

        public void IncrementI() { I++; }

        public int I { get; private set; }
    }

    // Simple class that contains Mutable structure
    // as readonly field
    class SomeClass 
    {
        public readonly Mutable mutable = new Mutable(5);
    }

    // Simple class that contains Mutable structure
    // as ordinary (non-readonly) field
    class AnotherClass 
    {
        public Mutable mutable = new Mutable(5);
    }

    class Program
    {
        void Main()
        {
            // Case 1. Mutable readonly field
            var someClass = new SomeClass();
            someClass.mutable.IncrementI();
            // still 5, not 6, because SomeClass.mutable field is readonly
            // and compiler creates temporary copy every time when you trying to
            // access this field
            Console.WriteLine(someClass.mutable.I);

            // Case 2. Mutable ordinary field
            var anotherClass = new AnotherClass();
            anotherClass.mutable.IncrementI();

            // Prints 6, because AnotherClass.mutable field is not readonly
            Console.WriteLine(anotherClass.mutable.I);
        }
    }

可變值類型和數組

假設我們有一個Mutable結構數組,並且我們正在為該數組的第一個元素調用IncrementI方法。 您期望此調用有什么行為? 它應該改變數組的值還是只改變一個副本?

    Mutable[] arrayOfMutables = new Mutable[1];
    arrayOfMutables[0] = new Mutable(5);

    // Now we actually accessing reference to the first element
    // without making any additional copy
    arrayOfMutables[0].IncrementI();

    // Prints 6!!
    Console.WriteLine(arrayOfMutables[0].I);

    // Every array implements IList<T> interface
    IList<Mutable> listOfMutables = arrayOfMutables;

    // But accessing values through this interface lead
    // to different behavior: IList indexer returns a copy
    // instead of an managed reference
    listOfMutables[0].IncrementI(); // Should change I to 7

    // Nope! we still have 6, because previous line of code
    // mutate a copy instead of a list value
    Console.WriteLine(listOfMutables[0].I);

因此,只要您和團隊的其他成員清楚地了解您在做什么,可變結構就不是邪惡的。 但是當程序行為與預期不同時,有太多的極端情況,這可能導致難以產生和難以理解的微妙錯誤。

值類型基本上代表不可變的概念。 Fx,擁有一個數學值(例如整數、向量等)然后能夠對其進行修改是沒有意義的。 這就像重新定義一個值的含義。 與其更改值類型,不如分配另一個唯一值更有意義。 考慮通過比較其屬性的所有值來比較值類型的事實。 關鍵是,如果屬性相同,那么它就是該值的相同通用表示。

正如 Konrad 所提到的,更改日期也沒有意義,因為該值表示該唯一的時間點,而不是具有任何狀態或上下文相關性的時間對象的實例。

希望這對你有意義。 可以肯定的是,它更多地是關於您嘗試用值類型捕獲的概念,而不是實際細節。

如果您曾經用 C/C++ 之類的語言進行過編程,那么結構體可以用作可變的。 只需將它們與 ref, around 一起傳遞,就不會出錯。 我發現的唯一問題是 C# 編譯器的限制,並且在某些情況下,我無法強制愚蠢的事情使用對結構的引用,而不是復制(就像當結構是 C# 類的一部分時)。

因此,可變結構並不邪惡,C#使它們變得邪惡。 我一直在 C++ 中使用可變結構,它們非常方便和直觀。 相比之下,C# 使我完全放棄結構作為類的成員,因為它們處理對象的方式。 他們的便利讓我們付出了代價。

如果您堅持結構的用途(在 C#、Visual Basic 6、Pascal/Delphi、C++ 結構類型(或類)中,當它們不用作指針時),您會發現結構只不過是一個復合變量. 這意味着:您會將它們視為一組打包的變量,使用通用名稱(您從中引用成員的記錄變量)。

我知道這會讓很多習慣於 OOP 的人感到困惑,但是如果使用得當,這不足以說明這些東西本質上是邪惡的。 有些結構是inmutable因為他們打算(這是Python的情況下namedtuple ),但它是另一種模式的考慮。

是的:結構涉及大量內存,但通過執行以下操作不會獲得更多內存:

point.x = point.x + 1

相比:

point = Point(point.x + 1, point.y)

內存消耗至少是相同的,在不可變的情況下甚至更多(盡管這種情況對於當前堆棧來說是暫時的,取決於語言)。

但是,最后,結構是結構,而不是對象。 在 POO 中,對象的主要屬性是其identity ,大多數情況下不超過其內存地址。 Struct 代表數據結構(不是一個合適的對象,所以它們無論如何都沒有身份),並且數據可以被修改。 在其他語言中, record (而不是struct ,就像 Pascal 的情況一樣)是這個詞並且具有相同的目的:只是一個數據記錄變量,旨在從文件中讀取、修改和轉儲到文件中(這是主要的使用,並且在許多語言中,您甚至可以在記錄中定義數據對齊,而正確調用對象的情況不一定如此)。

想要一個好的例子嗎? 結構用於輕松讀取文件。 Python 有這個庫是因為它是面向對象的,不支持結構體,所以它必須以另一種方式實現它,這有點難看。 實現結構的語言具有該功能......內置。 嘗試使用 Pascal 或 C 等語言讀取具有適當結構的位圖標頭。這很容易(如果結構正確構建和對齊;在 Pascal 中,您不會使用基於記錄的訪問,而是使用函數來讀取任意二進制數據)。 因此,對於文件和直接(本地)內存訪問,結構優於對象。 至於今天,我們已經習慣了 JSON 和 XML,所以我們忘記了二進制文件的使用(作為副作用,結構的使用)。 但是是的:它們存在,並且有目的。

他們並不邪惡。 只需將它們用於正確的目的。

如果你用錘子來思考,你會想把螺絲當釘子,發現螺絲更難插在牆上,是螺絲的錯,是壞的。

假設您有一個包含 1,000,000 個結構的數組。 每個結構都代表一個股權,其中包含bid_price、offer_price(可能是小數)等,這是由C#/VB 創建的。

想象一下,數組是在非托管堆中分配的內存塊中創建的,以便其他一些本機代碼線程能夠並發訪問該數組(也許是一些高性能代碼進行數學運算)。

想象一下,C#/VB 代碼正在偵聽價格變化的市場饋送,該代碼可能必須訪問數組的某些元素(無論哪種安全),然后修改某些價格字段。

想象一下,每秒執行數萬次甚至數十萬次。

好吧,讓我們面對事實,在這種情況下,我們確實希望這些結構是可變的,它們需要是可變的,因為它們被其他一些本機代碼共享,因此創建副本無濟於事; 他們需要這樣做,因為以這些速率復制一些 120 字節的結構是瘋狂的,尤其是當更新實際上可能只影響一兩個字節時。

雨果

當某些東西可以變異時,它就會獲得一種認同感。

struct Person {
    public string name; // mutable
    public Point position = new Point(0, 0); // mutable

    public Person(string name, Point position) { ... }
}

Person eric = new Person("Eric Lippert", new Point(4, 2));

因為Person是可變的,所以考慮改變 Eric 的位置比克隆 Eric、移動克隆和破壞原始. 這兩種操作都可以成功更改eric.position的內容,但一種比另一種更直觀。 同樣,將 Eric 傳遞給(作為參考)用於修改他的方法更直觀。 給一個方法一個 Eric 的克隆幾乎總是會令人驚訝。 任何想要改變Person必須記住要求對Person的引用,否則他們會做錯事。

如果您使類型不可變,問題就會消失; 如果我不能修改eric ,這都沒有區別,以我是否收到eric或克隆eric 更一般地說,如果一個類型的所有可觀察狀態都保存在以下任一成員中,則該類型可以安全地按值傳遞:

  • 不可變的
  • 引用類型
  • 按值傳遞是安全的

如果滿足這些條件,則可變值類型的行為類似於引用類型,因為淺拷貝仍將允許接收者修改原始數據。

一個不可變的Person的直觀性取決於你想要做什么。 如果Person只是代表一個Person組數據,那么它沒有什么不直觀的; Person變量真正代表抽象,而不是對象。 (在這種情況下,將其重命名為PersonData可能更合適。)如果Person實際上是在模擬一個人本身,那么即使您已經避免了認為自己的陷阱,不斷創建和移動克隆的想法也是愚蠢的。重新修改原文。 在這種情況下,簡單地使Person成為引用類型(即類)可能會更自然。

誠然,正如函數式編程告訴我們的那樣,讓一切都不可變是有好處的(沒有人可以秘密地持有對eric的引用並改變他),但是由於這在 OOP 中不是慣用的,因此對於其他使用它的人來說仍然是不直觀的你的代碼。

Eric Lippert 先生的例子有幾個問題。 人為地說明了復制結構的觀點,以及如果您不小心,這將如何成為問題。 看這個例子,我認為這是一個糟糕的編程習慣的結果,而不是結構或類的真正問題。

  1. 結構應該只有公共成員,並且不需要任何封裝。 如果是,那么它真的應該是一個類型/類。 你真的不需要兩個結構來表達同樣的事情。

  2. 如果你有一個封閉結構的類,你會調用類中的一個方法來改變成員結構。 這就是我會做的一個良好的編程習慣。

正確的實現如下。

struct Mutable {
public int x;
}

class Test {
    private Mutable m = new Mutable();
    public int mutate()
    { 
        m.x = m.x + 1;
        return m.x;
    }
  }
  static void Main(string[] args) {
        Test t = new Test();
        System.Console.WriteLine(t.mutate());
        System.Console.WriteLine(t.mutate());
        System.Console.WriteLine(t.mutate());
    }

看起來這是編程習慣的問題,而不是結構本身的問題。 結構應該是可變的,這就是想法和意圖。

更改的結果 voila 的行為符合預期:

1 2 3 按任意鍵繼續。 . .

它與結構無關(也與 C# 無關),但在 Java 中,當可變對象是哈希映射中的鍵時,您可能會遇到可變對象的問題。 如果在將它們添加到地圖后更改它們並更改其哈希碼,則可能會發生邪惡的事情。

可變數據有很多優點和缺點。 百萬美元的劣勢是混疊。 如果在多個地方使用相同的值,並且其中一個更改了它,那么它似乎已經神奇地更改為其他正在使用它的地方。 這與競爭條件有關,但不完全相同。

有時,百萬美元的優勢是模塊化。 可變狀態可以讓你隱藏代碼中不需要知道的變化信息。

The Art of the Interpreter詳細介紹了這些權衡,並給出了一些示例。

就我個人而言,當我查看代碼時,以下內容對我來說非常笨拙:

data.value.set(data.value.get()+1);

而不是簡單地

數據值++; 或 data.value = data.value + 1 ;

傳遞類時,數據封裝很有用,並且您希望確保以受控方式修改值。 但是,當您擁有公共 set 和 get 函數時,它們所做的只是將值設置為傳入的內容,與簡單地傳遞公共數據結構相比,這有何改進?

當我在類中創建私有結構時,我創建了該結構以將一組變量組織到一個組中。 我希望能夠在類范圍內修改該結構,而不是獲取該結構的副本並創建新實例。

對我來說,這會阻止有效使用用於組織公共變量的結構,如果我想要訪問控制,我會使用一個類。

如果使用得當,我不相信它們是邪惡的。 我不會把它放在我的生產代碼中,但我會像結構化單元測試模擬這樣的東西,其中結構的生命周期相對較短。

使用 Eric 示例,也許您想創建該 Eric 的第二個實例,但要進行調整,因為這是您測試的性質(即復制,然后修改)。 如果我們只是在測試腳本的其余部分使用 Eric2,那么 Eric 的第一個實例會發生什么並不重要,除非您打算使用他作為測試比較。

這對於測試或修改淺定義特定對象(結構點)的遺留代碼非常有用,但是通過具有不可變結構,這可以防止它的使用煩人。

暫無
暫無

聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.

 
粵ICP備18138465號  © 2020-2024 STACKOOM.COM