Tôi muốn nhảy vào đây trong số những câu trả lời đã rất xuất sắc này và thừa nhận rằng tôi đã áp dụng cách tiếp cận xấu xí khi thực sự làm việc ngược với mô hình chống thay đổi mã đa hình thành switches
hoặc if/else
các nhánh với mức tăng được đo. Nhưng tôi đã không làm việc bán buôn này, chỉ cho những con đường quan trọng nhất. Nó không phải là quá đen và trắng.
Từ chối trách nhiệm, tôi làm việc trong các lĩnh vực như raytracing, nơi mà tính chính xác không quá khó để đạt được (và dù sao cũng thường mờ và gần đúng) trong khi tốc độ thường là một trong những phẩm chất cạnh tranh nhất được tìm kiếm. Việc giảm thời gian kết xuất thường là một trong những yêu cầu phổ biến nhất của người dùng, với việc chúng tôi liên tục gãi đầu và tìm ra cách để đạt được nó cho các đường đo quan trọng nhất.
Tái cấu trúc đa hình của các điều kiện
Đầu tiên, đáng để hiểu tại sao đa hình có thể được ưa thích từ khía cạnh duy trì hơn là phân nhánh có điều kiện ( switch
hoặc một loạt các if/else
tuyên bố). Lợi ích chính ở đây là mở rộng .
Với mã đa hình, chúng tôi có thể giới thiệu một kiểu con mới cho cơ sở mã của chúng tôi, thêm các thể hiện của nó vào một số cấu trúc dữ liệu đa hình và có tất cả các mã đa hình hiện có vẫn hoạt động tự động mà không cần sửa đổi thêm. Nếu bạn có một loạt mã nằm rải rác trong một cơ sở mã lớn tương tự như dạng "Nếu loại này là 'foo', hãy làm điều đó" , bạn có thể thấy mình có một gánh nặng khủng khiếp khi cập nhật 50 phần mã khác nhau để giới thiệu một loại điều mới, và cuối cùng vẫn còn thiếu một vài thứ.
Các lợi ích duy trì của đa hình sẽ giảm đi một cách tự nhiên ở đây nếu bạn chỉ có một vài hoặc thậm chí một phần của cơ sở mã cần thực hiện kiểm tra kiểu như vậy.
Rào cản tối ưu hóa
Tôi sẽ đề nghị không xem xét điều này từ quan điểm phân nhánh và đường ống quá nhiều, và xem xét nó nhiều hơn từ tư duy thiết kế trình biên dịch của các rào cản tối ưu hóa. Có nhiều cách để cải thiện dự đoán nhánh áp dụng cho cả hai trường hợp, như sắp xếp dữ liệu dựa trên loại phụ (nếu nó phù hợp với một chuỗi).
Điều khác biệt hơn giữa hai chiến lược này là lượng thông tin mà trình tối ưu hóa có trước. Một cuộc gọi hàm được biết đến cung cấp nhiều thông tin hơn, một cuộc gọi hàm gián tiếp gọi một hàm không xác định tại thời gian biên dịch dẫn đến một rào cản tối ưu hóa.
Khi hàm được gọi, trình biên dịch có thể xóa sạch cấu trúc và nén nó xuống smithereens, thực hiện các cuộc gọi, loại bỏ các hàm răng cưa tiềm năng, thực hiện công việc tốt hơn trong phân bổ lệnh / đăng ký, thậm chí có thể sắp xếp lại các vòng lặp và các dạng khác của nhánh. switch
được mã hóa các LUT thu nhỏ khi thích hợp (một điều GCC 5.3 gần đây đã làm tôi ngạc nhiên với một tuyên bố bằng cách sử dụng LUT dữ liệu được mã hóa cứng cho các kết quả thay vì bảng nhảy).
Một số lợi ích bị mất khi chúng tôi bắt đầu đưa các ẩn số thời gian biên dịch vào hỗn hợp, như với trường hợp gọi hàm gián tiếp, và đó là nơi phân nhánh có điều kiện rất có thể mang lại lợi thế.
Tối ưu hóa bộ nhớ
Lấy một ví dụ về một trò chơi video bao gồm xử lý một chuỗi các sinh vật lặp đi lặp lại trong một vòng lặp chặt chẽ. Trong trường hợp như vậy, chúng ta có thể có một số thùng chứa đa hình như thế này:
vector<Creature*> creatures;
Lưu ý: để đơn giản tôi tránh unique_ptr
ở đây.
... Đâu Creature
là một loại cơ sở đa hình. Trong trường hợp này, một trong những khó khăn với các thùng chứa đa hình là chúng thường muốn phân bổ bộ nhớ cho từng kiểu con riêng biệt / riêng lẻ (ví dụ: sử dụng cách ném mặc định operator new
cho từng sinh vật riêng lẻ).
Điều đó thường sẽ tạo ưu tiên đầu tiên cho tối ưu hóa (chúng ta cần nó) dựa trên bộ nhớ thay vì phân nhánh. Một chiến lược ở đây là sử dụng một bộ cấp phát cố định cho từng loại phụ, khuyến khích một đại diện liền kề bằng cách phân bổ trong các khối lớn và bộ nhớ gộp cho mỗi loại phụ được phân bổ. Với chiến lược như vậy, chắc chắn có thể giúp sắp xếp creatures
thùng chứa này theo loại phụ (cũng như địa chỉ), vì điều đó không chỉ có thể cải thiện dự đoán chi nhánh mà còn cải thiện địa phương tham chiếu (cho phép truy cập nhiều sinh vật cùng loại. từ một dòng bộ đệm duy nhất trước khi trục xuất).
Phá hoại một phần cấu trúc dữ liệu và vòng lặp
Giả sử bạn đã trải qua tất cả các chuyển động này và bạn vẫn mong muốn tốc độ cao hơn. Điều đáng chú ý là mỗi bước chúng ta mạo hiểm ở đây đều làm giảm khả năng bảo trì và chúng ta sẽ ở giai đoạn mài mòn kim loại với lợi nhuận hiệu suất giảm dần. Vì vậy, cần phải có một nhu cầu hiệu suất khá đáng kể nếu chúng ta bước vào lãnh thổ này, nơi chúng ta sẵn sàng hy sinh khả năng bảo trì hơn nữa để đạt được hiệu suất nhỏ hơn và nhỏ hơn.
Tuy nhiên, bước tiếp theo để thử (và luôn sẵn sàng sao lưu các thay đổi của chúng tôi nếu nó không giúp ích gì cả) có thể là ảo hóa thủ công.
Mẹo kiểm soát phiên bản: trừ khi bạn am hiểu tối ưu hóa hơn tôi rất nhiều, có thể đáng để tạo một chi nhánh mới vào thời điểm này với sự sẵn sàng để ném nó đi nếu nỗ lực tối ưu hóa của chúng tôi rất có thể xảy ra. Đối với tôi, đó là tất cả các thử nghiệm và lỗi sau các loại điểm này ngay cả với một trình hồ sơ trong tay.
Tuy nhiên, chúng ta không phải áp dụng tư duy bán buôn này. Tiếp tục ví dụ của chúng tôi, giả sử trò chơi video này bao gồm chủ yếu là các sinh vật người. Trong trường hợp như vậy, chúng ta chỉ có thể làm ảo hóa các sinh vật người bằng cách nâng chúng ra và tạo ra một cấu trúc dữ liệu riêng cho chúng.
vector<Human> humans; // common case
vector<Creature*> other_creatures; // additional rare-case creatures
Điều này ngụ ý rằng tất cả các khu vực trong cơ sở mã của chúng ta cần xử lý sinh vật cần một vòng lặp trường hợp đặc biệt riêng cho sinh vật người. Tuy nhiên, điều đó giúp loại bỏ hàng rào công văn động (hoặc có lẽ, một cách thích hợp hơn, rào cản tối ưu hóa) cho con người, cho đến nay, là loại sinh vật phổ biến nhất. Nếu những khu vực này có số lượng lớn và chúng tôi có thể đủ khả năng, chúng tôi có thể làm điều này:
vector<Human> humans; // common case
vector<Creature*> other_creatures; // additional rare-case creatures
vector<Creature*> creatures; // contains humans and other creatures
... Nếu chúng ta có thể đủ khả năng này, các con đường ít quan trọng hơn có thể tồn tại như cũ và chỉ đơn giản là xử lý tất cả các loại sinh vật một cách trừu tượng. Các đường dẫn quan trọng có thể xử lý humans
trong một vòng lặp và other_creatures
trong vòng lặp thứ hai.
Chúng tôi có thể mở rộng chiến lược này khi cần thiết và có khả năng siết chặt một số lợi ích theo cách này, nhưng đáng chú ý là chúng tôi làm giảm khả năng bảo trì trong quá trình. Sử dụng các mẫu hàm ở đây có thể giúp tạo mã cho cả người và sinh vật mà không cần sao chép logic theo cách thủ công.
Phá hoại một phần các lớp học
Một cái gì đó tôi đã làm cách đây nhiều năm thực sự rất thô thiển, và tôi thậm chí không chắc nó còn có lợi nữa (đây là thời C ++ 03), là một phần sai lệch của một lớp. Trong trường hợp đó, chúng tôi đã lưu trữ một ID lớp với mỗi phiên bản cho các mục đích khác (được truy cập thông qua một trình truy cập trong lớp cơ sở không ảo). Ở đó chúng tôi đã làm một cái gì đó tương tự như thế này (trí nhớ của tôi hơi mơ hồ):
switch (obj->type())
{
case id_common_type:
static_cast<CommonType*>(obj)->non_virtual_do_something();
break;
...
default:
obj->virtual_do_something();
break;
}
... Nơi virtual_do_something
được triển khai để gọi các phiên bản không ảo trong một lớp con. Thật thô thiển, tôi biết, đang thực hiện một chương trình truyền hình tĩnh rõ ràng để làm sai lệch một cuộc gọi chức năng. Tôi không biết bây giờ nó có lợi như thế nào vì tôi đã không thử loại điều này trong nhiều năm. Với việc tiếp xúc với thiết kế hướng dữ liệu, tôi thấy chiến lược phân chia cấu trúc dữ liệu và vòng lặp theo kiểu nóng / lạnh sẽ hữu ích hơn nhiều, mở ra nhiều cánh cửa hơn cho các chiến lược tối ưu hóa (và ít xấu xí hơn).
Bán buôn ảo hóa
Tôi phải thừa nhận rằng tôi chưa bao giờ nhận được điều này khi áp dụng một tư duy tối ưu hóa, vì vậy tôi không biết gì về lợi ích. Tôi đã tránh các chức năng gián tiếp trong tầm nhìn xa trong trường hợp tôi biết rằng sẽ chỉ có một tập hợp các điều kiện trung tâm (ví dụ: xử lý sự kiện chỉ với một sự kiện xử lý vị trí trung tâm), nhưng không bao giờ bắt đầu với một tư duy đa hình và tối ưu hóa mọi cách đến đây.
Về mặt lý thuyết, lợi ích trước mắt ở đây có thể là một cách xác định loại nhỏ hơn tiềm năng so với con trỏ ảo (ví dụ: một byte nếu bạn có thể cam kết rằng có 256 loại duy nhất hoặc ít hơn) ngoài việc xóa bỏ hoàn toàn các rào cản tối ưu hóa này .
Trong một số trường hợp, nó cũng có thể giúp viết mã dễ bảo trì hơn (so với các ví dụ ảo hóa thủ công được tối ưu hóa ở trên) nếu bạn chỉ sử dụng một switch
câu lệnh trung tâm mà không phải phân tách cấu trúc dữ liệu và vòng lặp dựa trên kiểu con hoặc nếu có lệnh - sự phụ thuộc trong những trường hợp này khi mọi thứ phải được xử lý theo một thứ tự chính xác (ngay cả khi điều đó khiến chúng tôi phân nhánh khắp nơi). Điều này sẽ dành cho những trường hợp bạn không có quá nhiều nơi cần phải làm switch
.
Tôi thường không khuyến nghị điều này ngay cả với một tư duy rất quan trọng về hiệu suất trừ khi điều này khá dễ để duy trì. "Dễ bảo trì" sẽ có xu hướng xoay quanh hai yếu tố chính:
- Không có nhu cầu mở rộng thực sự (ví dụ: biết chắc chắn rằng bạn có chính xác 8 loại việc cần xử lý, và không bao giờ nữa).
- Không có nhiều vị trí trong mã của bạn cần kiểm tra các loại này (ví dụ: một vị trí trung tâm).
... Tuy nhiên, tôi đề xuất kịch bản trên trong hầu hết các trường hợp và lặp lại hướng tới các giải pháp hiệu quả hơn bằng cách ảo hóa một phần khi cần thiết. Nó cung cấp cho bạn nhiều phòng thở hơn để cân bằng giữa khả năng mở rộng và nhu cầu bảo trì với hiệu suất.
Hàm ảo so với Hàm con trỏ
Để loại bỏ điều này, tôi nhận thấy ở đây có một số cuộc thảo luận về các hàm ảo so với các con trỏ hàm. Đúng là các chức năng ảo đòi hỏi một chút công việc để gọi, nhưng điều đó không có nghĩa là chúng chậm hơn. Theo trực giác, nó thậm chí có thể làm cho họ nhanh hơn.
Nó phản trực giác ở đây bởi vì chúng ta thường đo lường chi phí theo hướng dẫn mà không chú ý đến tính năng động của hệ thống phân cấp bộ nhớ có xu hướng có tác động đáng kể hơn nhiều.
Nếu chúng ta so sánh a class
với 20 hàm ảo so với struct
lưu trữ 20 con trỏ hàm và cả hai đều được khởi tạo nhiều lần, thì chi phí bộ nhớ của mỗi class
trường hợp trong trường hợp này là 8 byte cho con trỏ ảo trên máy 64 bit, trong khi bộ nhớ tổng phí struct
là 160 byte.
Chi phí thực tế có thể có rất nhiều lỗi bộ nhớ cache bắt buộc và không bắt buộc với bảng con trỏ hàm so với lớp sử dụng các hàm ảo (và có thể lỗi trang ở quy mô đầu vào đủ lớn). Chi phí đó có xu hướng giảm bớt công việc hơi thêm của việc lập chỉ mục một bảng ảo.
Tôi cũng đã xử lý các cơ sở mã C cũ (cũ hơn tôi) khi chuyển các structs
con trỏ hàm đầy đủ và khởi tạo nhiều lần, thực sự đã tăng hiệu suất đáng kể (cải thiện hơn 100%) bằng cách biến chúng thành các lớp có chức năng ảo và chỉ đơn giản là do giảm đáng kể việc sử dụng bộ nhớ, tăng tính thân thiện với bộ nhớ cache, v.v.
Mặt khác, khi so sánh trở nên nhiều hơn về táo với táo, tôi cũng đã thấy suy nghĩ ngược lại về việc dịch từ tư duy chức năng ảo C ++ sang tư duy con trỏ chức năng kiểu C sẽ hữu ích trong các loại tình huống này:
class Functionoid
{
public:
virtual ~Functionoid() {}
virtual void operator()() = 0;
};
... Trong đó lớp đang lưu trữ một hàm overridable đơn lẻ (hoặc hai nếu chúng ta đếm hàm hủy ảo). Trong những trường hợp đó, nó chắc chắn có thể giúp trong các đường dẫn quan trọng để biến điều đó thành thế này:
void (*func_ptr)(void* instance_data);
... lý tưởng đằng sau một giao diện loại an toàn để ẩn các phôi nguy hiểm đến / từ void*
.
Trong những trường hợp chúng ta muốn sử dụng một lớp với một hàm ảo duy nhất, nó có thể nhanh chóng giúp sử dụng các con trỏ hàm thay thế. Một lý do lớn thậm chí không nhất thiết là giảm chi phí khi gọi một con trỏ hàm. Đó là bởi vì chúng ta không còn phải đối mặt với sự cám dỗ để phân bổ từng chức năng riêng biệt trên các vùng phân tán của heap nếu chúng ta tập hợp chúng thành một cấu trúc bền bỉ. Cách tiếp cận này có thể giúp dễ dàng hơn để tránh tình trạng phân mảnh bộ nhớ và phân mảnh bộ nhớ nếu dữ liệu cá thể là đồng nhất, ví dụ, và chỉ hành vi khác nhau.
Vì vậy, chắc chắn có một số trường hợp sử dụng con trỏ hàm có thể giúp ích, nhưng thường thì tôi đã tìm thấy nó theo cách khác nếu chúng ta so sánh một loạt các bảng con trỏ hàm với một vtable duy nhất chỉ yêu cầu một con trỏ được lưu trữ trên mỗi thể hiện của lớp . Vtable đó thường sẽ ngồi trong một hoặc nhiều dòng bộ đệm L1 cũng như các vòng lặp chặt chẽ.
Phần kết luận
Vì vậy, dù sao, đó là spin nhỏ của tôi về chủ đề này. Tôi khuyên bạn nên mạo hiểm trong các lĩnh vực này một cách thận trọng. Các phép đo tin cậy, không phải bản năng và được đưa ra theo cách các tối ưu hóa này thường làm giảm khả năng bảo trì, chỉ đi xa nhất có thể (và một lộ trình khôn ngoan sẽ là sai lầm về phía bảo trì).