Việc gọi hàm hủy theo cách thủ công luôn là một dấu hiệu của thiết kế xấu?


83

Tôi đang nghĩ: họ nói nếu bạn đang gọi máy hủy theo cách thủ công - bạn đang làm sai. Nhưng có phải luôn luôn như vậy không? Có bất kỳ ví dụ phản đối nào không? Những tình huống cần gọi là thủ công hay khó / không thể / không thực tế để tránh nó?


Làm thế nào để bạn xử lý đối tượng sau khi gọi dtor, mà không gọi lại nó?
ssube

2
@peachykeen: bạn sẽ gọi vị trí newđể khởi tạo một đối tượng mới thay cho đối tượng cũ. Nói chung không phải là một ý kiến ​​hay, nhưng nó không phải là chưa từng thấy.
D.Shawley

14
Nhìn vào "quy tắc" có chứa các từ "luôn luôn" và "không bao giờ" không xuất phát trực tiếp từ thông số kỹ thuật với nghi ngờ: trong hầu hết các trường hợp, người đang dạy họ muốn giấu bạn những điều bạn nên biết nhưng anh ta không biết cách dạy. Giống như một người lớn trả lời cho một đứa trẻ một câu hỏi về tình dục.
Emilio Garavaglia

Tôi nghĩ rằng nó ổn trong trường hợp thao tác với việc xây dựng các đối tượng với vị trí technic stroustrup.com/bs_faq2.html#placement-delete (nhưng nó là thứ ở cấp độ khá thấp và chỉ được sử dụng khi bạn tối ưu hóa phần mềm của mình ngay cả ở cấp độ như vậy)
bruziuz

Câu trả lời:


94

Việc gọi hàm hủy theo cách thủ công là bắt buộc nếu đối tượng được xây dựng bằng cách sử dụng dạng quá tải operator new(), ngoại trừ khi sử dụng " std::nothrow" quá tải:

T* t0 = new(std::nothrow) T();
delete t0; // OK: std::nothrow overload

void* buffer = malloc(sizeof(T));
T* t1 = new(buffer) T();
t1->~T(); // required: delete t1 would be wrong
free(buffer);

Tuy nhiên, bên ngoài việc quản lý bộ nhớ ở mức khá thấp như trên, việc gọi các hàm hủy một cách rõ ràng một dấu hiệu của thiết kế xấu. Có thể, nó thực sự không chỉ là một thiết kế tồi mà còn hoàn toàn sai (vâng, sử dụng một hàm hủy rõ ràng theo sau bởi một lệnh gọi hàm tạo bản sao trong toán tử gán một thiết kế tồi và có khả năng sai).

Với C ++ 2011, có một lý do khác để sử dụng lời gọi hàm hủy rõ ràng: Khi sử dụng kết hợp tổng quát, cần phải hủy rõ ràng đối tượng hiện tại và tạo đối tượng mới bằng cách sử dụng vị trí mới khi thay đổi kiểu của đối tượng được đại diện. Ngoài ra, khi kết hợp bị phá hủy, cần phải gọi rõ ràng trình hủy của đối tượng hiện tại nếu nó yêu cầu hủy.


26
Thay vì nói "sử dụng dạng nạp chồng của operator new", cụm từ chính xác là "sử dụng placement new".
Remy Lebeau

5
@RemyLebeau: Chà, tôi muốn làm rõ rằng tôi không chỉ nói về operator new(std::size_t, void*)(và biến thể mảng) mà còn về tất cả các phiên bản quá tải của operator new().
Dietmar Kühl

Còn khi bạn muốn sao chép một đối tượng để thực hiện một thao tác trong đó mà không thay đổi nó trong khi thao tác đó đang tính toán? temp = Class(object); temp.operation(); object.~Class(); object = Class(temp); temp.~Class();
Jean-Luc Nacif Coelho

yes, using an explicit destructor followed by a copy constructor call in the assignment operator is a bad design and likely to be wrong. Tại sao bạn lại nói vậy? Tôi sẽ nghĩ rằng nếu bộ hủy là nhỏ, hoặc gần tầm thường, nó có chi phí tối thiểu và tăng việc sử dụng nguyên tắc DRY. Nếu được sử dụng trong những trường hợp như vậy với một động thái operator=(), nó thậm chí có thể tốt hơn so với sử dụng hoán đổi. YMMV.
Adrian

1
@Adrian: gọi hàm hủy và tạo lại đối tượng rất dễ thay đổi kiểu của đối tượng: nó sẽ tạo lại một đối tượng với kiểu tĩnh của phép gán nhưng kiểu động có thể khác. Đó thực sự là một vấn đề khi lớp có các virtualhàm (các virtualhàm sẽ không được tạo lại) và nếu không thì đối tượng chỉ được xây dựng [lại] một phần.
Dietmar Kühl

101

Tất cả các câu trả lời đều mô tả các trường hợp cụ thể, nhưng có một câu trả lời chung:

Bạn gọi dtor một cách rõ ràng mỗi khi bạn chỉ cần hủy đối tượng (theo nghĩa C ++) mà không cần giải phóng bộ nhớ mà đối tượng nằm trong đó.

Điều này thường xảy ra trong tất cả các tình huống mà phân bổ / phân bổ bộ nhớ được quản lý độc lập với việc xây dựng / phá hủy đối tượng. Trong những trường hợp đó, việc xây dựng xảy ra thông qua vị trí mới trên một đoạn bộ nhớ tồn tại, và việc phá hủy xảy ra thông qua lệnh gọi dtor rõ ràng.

Đây là ví dụ thô:

{
  char buffer[sizeof(MyClass)];

  {
     MyClass* p = new(buffer)MyClass;
     p->dosomething();
     p->~MyClass();
  }
  {
     MyClass* p = new(buffer)MyClass;
     p->dosomething();
     p->~MyClass();
  }

}

Một ví dụ đáng chú ý khác là mặc định std::allocatorkhi được sử dụng bởi std::vector: các phần tử được xây dựng trong vectortrong push_back, nhưng bộ nhớ được cấp phát theo từng phần, vì vậy nó tồn tại trước phần tử contruction. Và do đó, vector::erasephải phá hủy các phần tử, nhưng không nhất thiết nó phải phân bổ bộ nhớ (đặc biệt nếu push_back mới phải sớm xảy ra ...).

Nó là "thiết kế xấu" theo nghĩa OOP nghiêm ngặt (bạn nên quản lý các đối tượng, không phải bộ nhớ: thực tế các đối tượng yêu cầu bộ nhớ là một "sự cố"), nó là "thiết kế tốt" trong "lập trình cấp thấp", hoặc trong trường hợp bộ nhớ không được lấy từ "cửa hàng miễn phí" mặc định operator newmua trong.

Đó là thiết kế tồi nếu nó xảy ra ngẫu nhiên xung quanh mã, nó là thiết kế tốt nếu nó xảy ra cục bộ với các lớp được thiết kế đặc biệt cho mục đích đó.


8
Chỉ tò mò là tại sao đây không phải là câu trả lời được chấp nhận.
Francis Cugler

11

Không, bạn không nên gọi nó một cách rõ ràng vì nó sẽ được gọi hai lần. Một lần cho lệnh gọi thủ công và một lần khác khi phạm vi mà đối tượng được khai báo kết thúc.

Ví dụ.

{
  Class c;
  c.~Class();
}

Nếu bạn thực sự cần thực hiện các thao tác tương tự, bạn nên có một phương pháp riêng.

Có một tình huống cụ thể mà bạn có thể muốn gọi một hàm hủy trên một đối tượng được phân bổ động có vị trí newnhưng nó không có vẻ gì đó bạn sẽ cần.


11

Không, phụ thuộc vào tình hình, đôi khi nó là thiết kế hợp pháp và tốt .

Để hiểu tại sao và khi nào bạn cần gọi hàm hủy một cách rõ ràng, hãy xem điều gì đang xảy ra với "mới" và "xóa".

Để tạo một đối tượng động, T* t = new T;dưới mui xe: 1. bộ nhớ sizeof (T) được cấp phát. 2. Hàm tạo của T được gọi để khởi tạo vùng nhớ được cấp phát. Toán tử mới thực hiện hai việc: cấp phát và khởi tạo.

Để phá hủy đối tượng delete t;dưới mui xe: 1. Trình hủy của T được gọi. 2. bộ nhớ được cấp phát cho đối tượng đó được giải phóng. toán tử xóa cũng thực hiện hai việc: hủy và phân bổ.

Người ta viết hàm tạo để khởi tạo và hàm hủy để thực hiện hủy. Khi bạn gọi hàm hủy một cách rõ ràng, chỉ có tác vụ hủy được thực hiện, nhưng không thực hiện việc phân bổ .

Do đó, một cách sử dụng hợp pháp của hàm hủy gọi rõ ràng có thể là, "Tôi chỉ muốn hủy đối tượng, nhưng tôi không (hoặc không thể) giải phóng cấp phát bộ nhớ (chưa)."

Một ví dụ phổ biến về điều này là cấp phát trước bộ nhớ cho một nhóm các đối tượng nhất định mà nếu không thì phải cấp phát động.

Khi tạo một đối tượng mới, bạn lấy phần bộ nhớ từ nhóm được cấp phát trước và thực hiện "vị trí mới". Sau khi thực hiện xong với đối tượng, bạn có thể muốn gọi hàm hủy một cách rõ ràng để hoàn thành công việc dọn dẹp, nếu có. Nhưng bạn sẽ không thực sự phân bổ bộ nhớ, như việc xóa toán tử đã thực hiện. Thay vào đó, bạn trả lại đoạn này vào pool để sử dụng lại.



6

Bất cứ lúc nào bạn cần tách phân bổ khỏi quá trình khởi tạo, bạn sẽ cần vị trí gọi trình hủy mới và rõ ràng theo cách thủ công. Ngày nay, nó hiếm khi cần thiết, vì chúng ta có các vùng chứa tiêu chuẩn, nhưng nếu bạn phải triển khai một số loại vùng chứa mới, bạn sẽ cần nó.


3

Có những trường hợp cần thiết:

Trong đoạn mã mà tôi làm việc, tôi sử dụng lệnh gọi hàm hủy rõ ràng trong trình cấp phát, tôi đã triển khai trình cấp phát đơn giản sử dụng vị trí mới để trả về các khối bộ nhớ cho vùng chứa stl. Trong tiêu diệt, tôi có:

  void destroy (pointer p) {
    // destroy objects by calling their destructor
    p->~T();
  }

trong khi xây dựng:

  void construct (pointer p, const T& value) {
    // initialize memory with placement new
    #undef new
    ::new((PVOID)p) T(value);
  }

cũng có phân bổ được thực hiện trong cấp phát () và phân bổ bộ nhớ trong deallocate (), sử dụng cơ chế cấp phát và dealloc cụ thể của nền tảng. Trình phân bổ này được sử dụng để bỏ qua doug lea malloc và sử dụng trực tiếp ví dụ LocalAlloc trên windows.


1

Tôi đã tìm thấy 3 cơ hội mà tôi cần làm điều này:

  • cấp phát / phân bổ đối tượng trong bộ nhớ được tạo bởi memory-mapping-io hoặc bộ nhớ chia sẻ
  • khi triển khai một giao diện C nhất định bằng C ++ (vâng, điều này rất tiếc ngày nay vẫn xảy ra (vì tôi không có đủ sức mạnh để thay đổi nó))
  • khi triển khai các lớp cấp phát

1

Tôi chưa bao giờ gặp phải tình huống mà người ta cần gọi một trình hủy theo cách thủ công. Tôi dường như nhớ ngay cả Stroustrup tuyên bố đó là một thực hành xấu.


1
Bạn nói đúng. Nhưng tôi đã sử dụng một vị trí mới. Tôi đã có thể thêm chức năng dọn dẹp trong một phương pháp khác với trình hủy. Trình hủy ở đó để nó có thể "tự động" được gọi khi ai đó xóa, khi bạn muốn hủy theo cách thủ công nhưng không phân bổ được, bạn chỉ cần viết một "onDestruct" phải không? Tôi sẽ quan tâm để nghe nếu có những ví dụ mà một đối tượng sẽ phải làm phá hủy của nó trong destructor bởi vì đôi khi bạn sẽ cần phải xóa và thời điểm khác bạn sẽ chỉ muốn hủy và không deallocate ..
Lieuwe

Và ngay cả trong trường hợp đó, bạn có thể gọi onDestruct () từ bên trong trình hủy - vì vậy tôi vẫn không thấy trường hợp nào để gọi trình hủy theo cách thủ công.
Lieuwe

4
@JimBalter: tác giả của C+
Đánh K Cowan

@MarkKCowan: C + là gì? Nó phải là C ++
Destructor

1

Cái này thì sao?
Bộ hủy không được gọi nếu một ngoại lệ được ném ra từ hàm tạo, vì vậy tôi phải gọi nó theo cách thủ công để hủy các chốt điều khiển đã được tạo trong hàm tạo trước ngoại lệ.

class MyClass {
  HANDLE h1,h2;
  public:
  MyClass() {
    // handles have to be created first
    h1=SomeAPIToCreateA();
    h2=SomeAPIToCreateB();        
    try {
      ...
      if(error) {
        throw MyException();
      }
    }
    catch(...) {
      this->~MyClass();
      throw;
    }
  }
  ~MyClass() {
    SomeAPIToDestroyA(h1);
    SomeAPIToDestroyB(h2);
  }
};

1
Điều này có vẻ có vấn đề: khi hàm tạo của bạn lướt qua, bạn không biết (hoặc có thể không biết) phần nào của đối tượng đã được xây dựng và phần nào chưa. Vì vậy, bạn không biết đối tượng con nào để gọi hàm hủy. Hoặc tài nguyên nào được phân bổ bởi phương thức khởi tạo để phân bổ.
Violet Giraffe,

@VioletGiraffe nếu các đối tượng con được xây dựng trên ngăn xếp, tức là không phải với "mới", chúng sẽ tự động bị phá hủy. Nếu không, bạn có thể kiểm tra xem chúng có phải là NULL hay không trước khi hủy chúng trong trình hủy. Tương tự với các tài nguyên
CITBL

Cách bạn viết ctorở đây là sai, chính xác vì lý do bạn tự cung cấp: nếu phân bổ tài nguyên không thành công, thì có vấn đề với việc dọn dẹp. Một 'ctor' không nên gọi this->~dtor(). dtornên được gọi trên các đối tượng đã xây dựng , và trong trường hợp này đối tượng chưa được xây dựng. Bất cứ điều gì xảy ra, ctornên xử lý việc dọn dẹp. Bên trong ctormã, bạn nên sử dụng utils như std::unique_ptrđể xử lý việc dọn dẹp tự động cho bạn trong trường hợp có thứ gì đó ném ra. Thay đổi HANDLE h1, h2các trường trong lớp để hỗ trợ dọn dẹp tự động cũng có thể là một ý tưởng hay.
quetzalcoatl,

Điều này có nghĩa là ctor sẽ trông như thế nào: MyClass(){ cleanupGuard1<HANDLE> tmp_h1(&SomeAPIToDestroyA) = SomeAPIToCreateA(); cleanupGuard2<HANDLE> tmp_h2(&SomeAPIToDestroyB) = SomeAPIToCreateB(); if(error) { throw MyException(); } this->h1 = tmp_h1.release(); this->h2 = tmp_h2.release(); }thế là xong . Không phải dọn dẹp thủ công đầy rủi ro, không cần lưu trữ tay cầm trong vật thể được xây dựng một phần cho đến khi mọi thứ an toàn là một phần thưởng. Nếu bạn thay đổi HANDLE h1,h2trong lớp thành cleanupGuard<HANDLE> h1;vv, thì bạn thậm chí có thể không cần dtorđến.
quetzalcoatl

Việc triển khai cleanupGuard1cleanupGuard2phụ thuộc vào giá trị xxxToCreatetrả về có liên quan và tham số nào có liên quan xxxxToDestroy. Nếu chúng đơn giản, bạn thậm chí có thể không cần viết bất cứ điều gì, vì nó thường hóa ra std::unique_ptr<x,deleter()>(hoặc một cái tương tự) có thể giúp bạn trong cả hai trường hợp.
quetzalcoatl

-2

Tìm thấy một ví dụ khác mà bạn sẽ phải gọi (các) hàm hủy theo cách thủ công. Giả sử bạn đã triển khai một lớp giống biến thể chứa một trong một số loại dữ liệu:

struct Variant {
    union {
        std::string str;
        int num;
        bool b;
    };
    enum Type { Str, Int, Bool } type;
};

Nếu Variantphiên bản đang giữ a std::stringvà bây giờ bạn đang gán một kiểu khác cho liên hợp, bạn phải hủy kiểu std::stringđầu tiên. Trình biên dịch sẽ không làm điều đó tự động .


-4

Tôi có một tình huống khác mà tôi nghĩ rằng nó là hoàn toàn hợp lý để gọi trình hủy.

Khi viết phương thức kiểu "Reset" để khôi phục một đối tượng về trạng thái ban đầu, việc gọi Destructor để xóa dữ liệu cũ đang được reset là hoàn toàn hợp lý.

class Widget
{
private: 
    char* pDataText { NULL  }; 
    int   idNumber  { 0     };

public:
    void Setup() { pDataText = new char[100]; }
    ~Widget()    { delete pDataText;          }

    void Reset()
    {
        Widget blankWidget;
        this->~Widget();     // Manually delete the current object using the dtor
        *this = blankObject; // Copy a blank object to the this-object.
    }
};

1
Sẽ không trông gọn gàng hơn nếu bạn khai báo một cleanup()phương thức đặc biệt được gọi trong trường hợp này trong trình hủy?
Violet Giraffe

Một phương thức "đặc biệt" chỉ được gọi trong hai trường hợp? Chắc chắn ... điều đó nghe hoàn toàn chính xác (/ mỉa mai). Các phương pháp nên được tổng quát hóa và có thể được gọi ở bất kỳ đâu. Khi bạn muốn xóa một đối tượng, không có gì sai khi gọi hàm hủy của nó.
abelenky

4
Bạn không được gọi hàm hủy một cách rõ ràng trong trường hợp này. Dù sao thì bạn cũng phải triển khai toán tử gán.
Rémi 13:17
Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.