Hiệu quả của việc trả lại sớm trong một chức năng


97

Đây là một tình huống mà tôi thường xuyên gặp phải với tư cách là một lập trình viên thiếu kinh nghiệm và tôi đang băn khoăn về việc đặc biệt đối với một dự án đầy tham vọng, đòi hỏi nhiều tốc độ của tôi mà tôi đang cố gắng tối ưu hóa. Đối với các ngôn ngữ giống C chính (C, objC, C ++, Java, C #, v.v.) và các trình biên dịch thông thường của chúng, liệu hai hàm này có chạy hiệu quả không? Có sự khác biệt nào trong mã biên dịch không?

void foo1(bool flag)
{
    if (flag)
    {
        //Do stuff
        return;
    }

    //Do different stuff
}

void foo2(bool flag)
{
    if (flag)
    {
        //Do stuff
    }
    else
    {
        //Do different stuff
    }
}

Về cơ bản, có bao giờ thưởng / phạt hiệu quả trực tiếp khi nhập breakhoặc nhập returnsớm không? Khung ngăn xếp có liên quan như thế nào? Có những trường hợp đặc biệt được tối ưu hóa không? Có bất kỳ yếu tố nào (như nội tuyến hoặc kích thước của "Do nội dung") có thể ảnh hưởng đáng kể đến điều này không?

Tôi luôn là người đề xuất tính dễ đọc được cải thiện qua các tối ưu hóa nhỏ (tôi thấy foo1 rất nhiều khi có xác thực thông số), nhưng điều này xuất hiện thường xuyên đến mức tôi muốn gạt bỏ mọi lo lắng một lần và mãi mãi.

Và tôi biết những cạm bẫy của việc tối ưu hóa quá sớm ... ugh, đó là một số ký ức đau buồn.

CHỈNH SỬA: Tôi đã chấp nhận một câu trả lời, nhưng câu trả lời của EJP giải thích khá ngắn gọn lý do tại sao việc sử dụng một returnthực tế là không đáng kể (trong hợp ngữ, hàm returntạo ra một 'nhánh' ở cuối hàm, điều này cực kỳ nhanh. Nhánh thay đổi thanh ghi PC và cũng có thể ảnh hưởng đến bộ nhớ cache và đường ống dẫn, điều này khá nhỏ.) Đặc biệt, đối với trường hợp này, nó thực sự không có gì khác biệt vì cả hàm if/elsevà đều returntạo ra cùng một nhánh cho đến cuối hàm.


22
Tôi không nghĩ những thứ như vậy sẽ có tác động đáng kể đến hiệu suất. Chỉ cần viết một bài kiểm tra nhỏ và xem bản thân bạn. Imo, phiên bản đầu tiên là tốt hơn vì bạn không được làm tổ không cần thiết mà cải thiện readablitiy
SirVaulterScoff

10
@SirVaulterScott, trừ khi hai trường hợp đối xứng theo một cách nào đó, trong trường hợp đó, bạn muốn tạo ra sự đối xứng bằng cách đặt chúng ở cùng một mức độ thụt lề.
luqui

3
SirVaulterScoff: +1 để giảm không cần thiết làm tổ
fjdumont

11
Khả năng đọc >>> Tối ưu hóa vi mô. Hãy làm theo bất kỳ cách nào có ý nghĩa hơn đối với những người sẽ duy trì việc này. Ở cấp độ mã máy, hai cấu trúc này giống hệt nhau khi được đưa vào một trình biên dịch khá ngu ngốc. Một trình biên dịch tối ưu hóa sẽ xóa bỏ bất kỳ khoảng cách nào về lợi thế tốc độ giữa hai loại.
SplinterReality

12
Đừng tối ưu hóa dự án "tăng tốc" của bạn bằng cách lo lắng về những thứ như thế này. Cấu hình ứng dụng của bạn để tìm hiểu xem nó thực sự chậm ở đâu - nếu nó thực sự quá chậm khi bạn hoàn thành việc làm cho nó hoạt động. Bạn gần như chắc chắn không thể đoán được điều gì đang thực sự làm chậm nó.
blueshift

Câu trả lời:


92

Không có sự khác biệt nào cả:

=====> cat test_return.cpp
extern void something();
extern void something2();

void test(bool b)
{
    if(b)
    {
        something();
    }
    else
        something2();
}
=====> cat test_return2.cpp
extern void something();
extern void something2();

void test(bool b)
{
    if(b)
    {
        something();
        return;
    }
    something2();
}
=====> rm -f test_return.s test_return2.s
=====> g++ -S test_return.cpp 
=====> g++ -S test_return2.cpp 
=====> diff test_return.s test_return2.s
=====> rm -f test_return.s test_return2.s
=====> clang++ -S test_return.cpp 
=====> clang++ -S test_return2.cpp 
=====> diff test_return.s test_return2.s
=====> 

Có nghĩa là không có sự khác biệt nào về mã được tạo ngay cả khi không có tối ưu hóa trong hai trình biên dịch


59
Hoặc tốt hơn: có ít nhất một phiên bản của một trình biên dịch nhất định tạo ra cùng một mã cho hai phiên bản.
UncleZeiv

11
@UncleZeiv - hầu hết nếu không phải tất cả các trình biên dịch sẽ dịch nguồn sang mô hình biểu đồ luồng thực thi. Thật khó để tưởng tượng một triển khai lành mạnh sẽ cung cấp các biểu đồ luồng khác nhau có ý nghĩa cho hai ví dụ đó. Về sự khác biệt duy nhất mà bạn có thể thấy là hai việc khác nhau được hoán đổi - và thậm chí điều đó cũng có thể được hoàn tác trong nhiều triển khai để tối ưu hóa dự đoán nhánh hoặc cho một số vấn đề khác trong đó nền tảng xác định thứ tự ưu tiên.
Steve314

6
@ Steve314, chắc chắn, tôi chỉ nitpicking :)
UncleZeiv

@UncleZeiv: thử nghiệm trên kêu vang quá và kết quả tương tự
Dani

Tôi không hiểu. Rõ ràng là điều đó something()sẽ luôn được thực thi. Trong câu hỏi ban đầu, OP có Do stuffDo diffferent stufftùy thuộc vào cờ. Tôi không nghĩ rằng mã được tạo sẽ giống nhau.
Luc M

65

Câu trả lời ngắn gọn là, không có sự khác biệt. Hãy tự làm một việc và ngừng lo lắng về điều này. Trình biên dịch tối ưu hóa hầu như luôn luôn thông minh hơn bạn.

Tập trung vào khả năng đọc và khả năng bảo trì.

Nếu bạn muốn xem điều gì sẽ xảy ra, hãy xây dựng chúng với các tính năng tối ưu và xem đầu ra của trình lắp ráp.


8
@Philip: Và hãy ủng hộ mọi người và đừng lo lắng về điều này nữa. Mã bạn viết cũng sẽ được đọc và duy trì bởi người khác (và thậm chí nếu bạn viết mà không bao giờ người khác đọc, bạn vẫn phát triển thói quen ảnh hưởng đến mã khác mà bạn viết sẽ được người khác đọc). Luôn viết mã sao cho dễ hiểu nhất có thể.
hlovdal

8
Trình tối ưu hóa không thông minh hơn bạn !!! Họ chỉ nhanh hơn trong việc quyết định nơi tác động không quan trọng nhiều. Nơi nó thực sự quan trọng, bạn chắc chắn sẽ có một số kinh nghiệm tối ưu hóa tốt hơn trình biên dịch.
johannes

10
@johannes Hãy để tôi không đồng ý. Trình biên dịch sẽ không thay đổi thuật toán của bạn để tốt hơn, nhưng nó thực hiện một công việc đáng kinh ngạc trong việc sắp xếp lại các hướng dẫn để đạt được hiệu quả đường ống tối đa và những thứ không quá tầm thường khác đối với các vòng lặp (phân hạch, hợp nhất, v.v.) mà ngay cả một lập trình viên có kinh nghiệm cũng không thể quyết định tiên nghiệm tốt hơn là gì trừ khi anh ta có kiến ​​thức sâu sắc về kiến ​​trúc CPU.
fortran

3
@johannes - đối với câu hỏi này, bạn có thể cho rằng nó đúng. Ngoài ra, nói chung, đôi khi bạn có thể tối ưu hóa tốt hơn trình biên dịch trong một số trường hợp đặc biệt nhưng điều đó cần một chút kiến ​​thức chuyên môn ngày nay - trường hợp bình thường là trình tối ưu hóa áp dụng hầu hết các cách tối ưu mà bạn có thể nghĩ đến và làm như vậy một cách có hệ thống chứ không chỉ trong một vài trường hợp đặc biệt. Trả lời câu hỏi này, trình biên dịch có thể sẽ xây dựng chính xác cùng một biểu đồ luồng thực thi cho cả hai dạng. Chọn một thuật toán tốt hơn là công việc của con người, nhưng việc tối ưu hóa cấp độ mã hầu như luôn luôn lãng phí thời gian.
Steve314,

4
Tôi đồng ý và không đồng ý với điều này. Có những trường hợp khi trình biên dịch không thể biết rằng thứ gì đó tương đương với thứ khác. Bạn có biết nó thường làm nhanh x = <some number>hơn if(<would've changed>) x = <some number>những cành cây chưa mọc có thể thực sự bị thương. Mặt khác, trừ khi điều này nằm trong vòng lặp chính của một hoạt động cực kỳ chuyên sâu, tôi cũng sẽ không lo lắng về nó.
user606723

28

Câu trả lời thú vị: Mặc dù tôi đồng ý với tất cả chúng (cho đến nay), có thể có những ý nghĩa cho câu hỏi này mà cho đến nay vẫn hoàn toàn bị bỏ qua.

Nếu ví dụ đơn giản ở trên được mở rộng với phân bổ tài nguyên và sau đó kiểm tra lỗi với khả năng giải phóng tài nguyên, bức tranh có thể thay đổi.

Hãy xem xét cách tiếp cận ngây thơ mà người mới bắt đầu có thể áp dụng:

int func(..some parameters...) {
  res_a a = allocate_resource_a();
  if (!a) {
    return 1;
  }
  res_b b = allocate_resource_b();
  if (!b) {
    free_resource_a(a);
    return 2;
  }
  res_c c = allocate_resource_c();
  if (!c) {
    free_resource_b(b);
    free_resource_a(a);
    return 3;
  }

  do_work();

  free_resource_c(c);
  free_resource_b(b);
  free_resource_a(a);

  return 0;
}

Những điều trên sẽ đại diện cho một phiên bản cực đoan của phong cách quay trở lại sớm. Lưu ý rằng mã trở nên rất lặp đi lặp lại và không thể bảo trì theo thời gian khi độ phức tạp của nó tăng lên. Ngày nay, mọi người có thể sử dụng xử lý ngoại lệ để bắt những thứ này.

int func(..some parameters...) {
  res_a a;
  res_b b;
  res_c c;

  try {
    a = allocate_resource_a(); # throws ExceptionResA
    b = allocate_resource_b(); # throws ExceptionResB
    c = allocate_resource_c(); # throws ExceptionResC
    do_work();
  }  
  catch (ExceptionBase e) {
    # Could use type of e here to distinguish and
    # use different catch phrases here
    # class ExceptionBase must be base class of ExceptionResA/B/C
    if (c) free_resource_c(c);
    if (b) free_resource_b(b);
    if (a) free_resource_a(a);
    throw e
  }
  return 0;
}

Philip đề xuất, sau khi xem ví dụ goto bên dưới, sử dụng một công tắc / trường hợp ít ngắt bên trong khối bắt ở trên. Người ta có thể chuyển đổi (typeof (e)) và sau đó chuyển qua các free_resourcex()cuộc gọi nhưng điều này không phảinhỏ và cần xem xét thiết kế . Và hãy nhớ rằng một công tắc / trường hợp không có dấu ngắt giống hệt như goto với các nhãn theo chuỗi bên dưới ...

Như Mark B đã chỉ ra, trong C ++, nó được coi là phong cách tốt để tuân theo nguyên tắc Resource Aquisition là Initialization , nói ngắn gọn là RAII . Ý chính của khái niệm này là sử dụng sự khởi tạo đối tượng để thu thập tài nguyên. Các tài nguyên sau đó sẽ tự động được giải phóng ngay khi các đối tượng đi ra khỏi phạm vi và trình hủy của chúng được gọi. Đối với các tài nguyên phụ thuộc lẫn nhau, cần đặc biệt chú ý để đảm bảo thứ tự phân bổ chính xác và thiết kế các loại đối tượng sao cho có sẵn dữ liệu cần thiết cho tất cả các trình hủy.

Hoặc trong những ngày trước ngoại lệ có thể làm:

int func(..some parameters...) {
  res_a a = allocate_resource_a();
  res_b b = allocate_resource_b();
  res_c c = allocate_resource_c();
  if (a && b && c) {   
    do_work();
  }  
  if (c) free_resource_c(c);
  if (b) free_resource_b(b);
  if (a) free_resource_a(a);

  return 0;
}

Nhưng ví dụ đơn giản hóa quá mức này có một số nhược điểm: Nó chỉ có thể được sử dụng nếu các tài nguyên được cấp phát không phụ thuộc vào nhau (ví dụ: nó không thể được sử dụng để cấp phát bộ nhớ, sau đó mở một trình xử lý tệp, sau đó đọc dữ liệu từ xử lý vào bộ nhớ ), và nó không cung cấp các mã lỗi riêng lẻ, có thể phân biệt được dưới dạng giá trị trả về.

Để giữ cho mã nhanh (!), Nhỏ gọn, dễ đọc và có thể mở rộng Linus Torvalds đã thực thi một kiểu khác cho mã nhân xử lý tài nguyên, thậm chí sử dụng goto khét tiếng theo cách hoàn toàn hợp lý :

int func(..some parameters...) {
  res_a a;
  res_b b;
  res_c c;

  a = allocate_resource_a() || goto error_a;
  b = allocate_resource_b() || goto error_b;
  c = allocate_resource_c() || goto error_c;

  do_work();

error_c:
  free_resource_c(c);
error_b:
  free_resource_b(b);
error_a:
  free_resource_a(a);

  return 0;
}

Ý chính của cuộc thảo luận về danh sách gửi thư hạt nhân là hầu hết các tính năng ngôn ngữ được "ưu tiên" hơn câu lệnh goto là các gotos ngầm, chẳng hạn như if / else lớn, giống như cây, trình xử lý ngoại lệ, câu lệnh loop / break / continue, v.v. Và các goto trong ví dụ trên được coi là ổn, vì chúng chỉ nhảy một khoảng cách nhỏ, có nhãn rõ ràng và giải phóng mã của các lỗi lộn xộn khác để theo dõi các điều kiện lỗi. Câu hỏi này cũng đã được thảo luận ở đây trên stackoverflow .

Tuy nhiên, những gì còn thiếu trong ví dụ cuối cùng là một cách hay để trả về mã lỗi. Tôi đã nghĩ đến việc thêm một result_code++sau mỗi free_resource_x()cuộc gọi và trả lại mã đó, nhưng điều này làm mất đi một số mức tăng tốc độ của kiểu mã hóa ở trên. Và thật khó để trả về 0 trong trường hợp thành công. Có lẽ tôi chỉ không tưởng tượng ;-)

Vì vậy, vâng, tôi nghĩ rằng có một sự khác biệt lớn trong câu hỏi mã hóa lợi nhuận sớm hay không. Nhưng tôi cũng nghĩ rằng nó chỉ rõ ràng trong mã phức tạp hơn khó hoặc không thể tái cấu trúc và tối ưu hóa cho trình biên dịch. Điều này thường xảy ra khi việc phân bổ tài nguyên có hiệu lực.


1
Wow, thực sự thú vị. Tôi chắc chắn có thể đánh giá cao tính không thể nhầm lẫn của cách tiếp cận ngây thơ. Tuy nhiên, việc xử lý ngoại lệ sẽ cải thiện như thế nào đối với trường hợp cụ thể đó? Giống như một câu lệnh catchchứa một switchcâu lệnh ngắt quãng trên mã lỗi?
Philip Guin

@Philip Đã thêm ví dụ xử lý ngoại lệ cơ bản. Lưu ý rằng chỉ có goto mới có khả năng rơi. Công tắc đề xuất của bạn (typeof (e)) sẽ hữu ích, nhưng không phảinhỏ và cần xem xét thiết kế . Và hãy nhớ rằng một switch / trường hợp mà không phá vỡ hoàn toàn giống với các goto với nhãn daisy-xích ;-)
CFI

+1 đây là câu trả lời chính xác cho C / C ++ (hoặc bất kỳ ngôn ngữ nào yêu cầu giải phóng bộ nhớ theo cách thủ công). Cá nhân tôi không thích phiên bản nhiều nhãn. Ở công ty trước đây của tôi, nó luôn là "goto fin" (đó là một công ty của Pháp). Trong phần vây, chúng tôi sẽ loại bỏ bất kỳ bộ nhớ nào và đó là cách sử dụng duy nhất của goto để vượt qua quá trình xem xét mã.
Kip

1
Lưu ý rằng trong C ++, bạn sẽ không thực hiện bất kỳ cách tiếp cận nào trong số này, nhưng sẽ sử dụng RAII để đảm bảo rằng các tài nguyên được dọn dẹp đúng cách.
Mark B

12

Mặc dù đây không phải là câu trả lời nhiều, nhưng trình biên dịch sản xuất sẽ tối ưu hóa tốt hơn nhiều so với bạn. Tôi sẽ ủng hộ khả năng đọc và khả năng bảo trì hơn các loại tối ưu hóa này.


9

Để cụ thể về điều này, returnsẽ được biên dịch thành một nhánh ở cuối phương thức, nơi sẽ có một REThướng dẫn hoặc bất cứ điều gì có thể có. Nếu bạn bỏ nó ra ngoài, phần cuối của khối trước khối elsesẽ được biên dịch thành một nhánh đến cuối elsekhối. Vì vậy, bạn có thể thấy trong trường hợp cụ thể này, nó không có gì khác biệt.


Gotcha. Tôi thực sự nghĩ rằng điều này trả lời câu hỏi của tôi khá ngắn gọn; Tôi đoán nó thực sự chỉ là một bổ sung đăng ký, khá không đáng kể (trừ khi có thể bạn đang lập trình hệ thống, và thậm chí sau đó ...) Tôi sẽ đề cập đến điều này một cách danh dự.
Philip Guin

@Philip bổ sung đăng ký gì? Không có hướng dẫn bổ sung nào trong đường dẫn cả.
Marquis of Lorne,

Vâng, cả hai sẽ có bổ sung đăng ký. Đó là tất cả một nhánh lắp ráp, phải không? Một bổ sung cho quầy chương trình? Tôi có thể sai ở đây.
Philip Guin,

1
@Philip Không, một nhánh lắp ráp là một nhánh lắp ráp. Nó không ảnh hưởng đến máy tính của khóa học nhưng nó có thể là do hoàn toàn tải lại nó, và nó cũng có tác dụng phụ trong bộ vi xử lý wrt các đường ống dẫn, bộ nhớ đệm, vv
Marquis của Lorne

4

Nếu bạn thực sự muốn biết liệu có sự khác biệt trong mã đã biên dịch cho trình biên dịch và hệ thống cụ thể của mình hay không, bạn sẽ phải tự biên dịch và xem xét lắp ráp.

Tuy nhiên, trong sơ đồ lớn của mọi thứ, gần như chắc chắn rằng trình biên dịch có thể tối ưu hóa tốt hơn so với khả năng tinh chỉnh của bạn và ngay cả khi nó không thể, nó rất khó thực sự quan trọng đối với hiệu suất chương trình của bạn.

Thay vào đó, hãy viết mã theo cách rõ ràng nhất để con người có thể đọc và duy trì, đồng thời để trình biên dịch làm những gì nó làm tốt nhất: Tạo lắp ráp tốt nhất có thể từ nguồn của bạn.


4

Trong ví dụ của bạn, lợi nhuận là đáng chú ý. Điều gì xảy ra với người gỡ lỗi khi kết quả trả về là một hoặc hai trang trên / dưới nơi // có những thứ khác nhau xảy ra? Khó tìm / thấy hơn nhiều khi có nhiều mã hơn.

void foo1(bool flag)
{
    if (flag)
    {
        //Do stuff
        return;
    }

    //Do different stuff
}

void foo2(bool flag)
{
    if (flag)
    {
        //Do stuff
    }
    else
    {
        //Do different stuff
    }
}

Tất nhiên, một hàm không được dài hơn một (hoặc thậm chí hai) trang. Nhưng khía cạnh gỡ lỗi vẫn chưa được đề cập trong bất kỳ câu trả lời nào khác. Lấy điểm!
cfi

3

Tôi hoàn toàn đồng ý với blueshift: tính dễ đọc và khả năng bảo trì trước tiên !. Nhưng nếu bạn thực sự lo lắng (hoặc chỉ muốn tìm hiểu xem trình biên dịch của bạn đang làm gì, đây chắc chắn là một ý tưởng tốt về lâu dài), bạn nên tự tìm kiếm.

Điều này có nghĩa là sử dụng trình dịch ngược hoặc xem đầu ra của trình biên dịch ở mức thấp (ví dụ như ngôn ngữ lắp ráp). Trong C # hoặc bất kỳ ngôn ngữ .Net nào, các công cụ được ghi lại ở đây sẽ cung cấp cho bạn những gì bạn cần.

Nhưng như bản thân bạn đã quan sát, đây có thể là sự tối ưu hóa quá sớm.


1

Từ mã sạch: Sổ tay thủ công phần mềm Agile

Đối số cờ là xấu. Truyền một boolean vào một hàm là một việc thực sự khủng khiếp. Nó ngay lập tức làm phức tạp chữ ký của phương thức, lớn tiếng tuyên bố rằng hàm này thực hiện nhiều hơn một việc. Nó thực hiện một việc nếu cờ đúng và một việc khác nếu cờ sai!

foo(true);

trong mã sẽ chỉ khiến người đọc điều hướng đến hàm và lãng phí thời gian đọc foo (cờ boolean)

Cơ sở mã có cấu trúc tốt hơn sẽ mang lại cho bạn cơ hội tốt hơn để tối ưu hóa mã.


Tôi chỉ lấy cái này làm ví dụ. Những gì được chuyển vào hàm có thể là một int, double, một lớp, bạn đặt tên cho nó, nó không thực sự là trọng tâm của vấn đề.
Philip Guin

Câu hỏi bạn hỏi là về việc chuyển đổi bên trong chức năng của bạn, hầu hết trường hợp, đó là mùi mã. Nó có thể đạt được nhiều cách và người đọc không cần phải đọc toàn bộ chức năng đó, hãy nói foo (28) nghĩa là gì?
dân tệ

0

Một trường phái suy nghĩ (không thể nhớ người đầu tiên đã đề xuất nó vào lúc này) là tất cả hàm chỉ nên có một điểm trả về theo quan điểm cấu trúc để làm cho mã dễ đọc và gỡ lỗi hơn. Tôi cho rằng điều đó nhiều hơn để lập trình tranh luận về tôn giáo.

Một lý do kỹ thuật mà bạn có thể muốn kiểm soát khi nào và cách một hàm thoát vi phạm quy tắc này là khi bạn đang mã hóa các ứng dụng thời gian thực và bạn muốn đảm bảo rằng tất cả các đường dẫn điều khiển thông qua hàm đều có cùng một số chu kỳ đồng hồ để hoàn thành.


Uh, tôi nghĩ nó liên quan đến việc dọn dẹp (đặc biệt là khi viết mã bằng C).
Thomas Eding

không, bất kể bạn để một phương thức ở đâu, miễn là bạn trả lại ngăn xếp được đẩy xuống (đó là tất cả những gì được "dọn dẹp").
MartyTPS

-4

Tôi rất vui vì bạn đã đưa ra câu hỏi này. Bạn nên luôn luôn sử dụng các nhánh trong một ngày trở lại sớm. Tại sao lại dừng ở đó? Hợp nhất tất cả các chức năng của bạn thành một nếu bạn có thể (ít nhất là nhiều nhất có thể). Điều này có thể thực hiện được nếu không có đệ quy. Cuối cùng, bạn sẽ có một chức năng chính lớn, nhưng đó là những gì bạn cần / muốn cho loại thứ này. Sau đó, đổi tên mã nhận dạng của bạn để càng ngắn càng tốt. Bằng cách đó, khi mã của bạn được thực thi, ít thời gian hơn dành cho việc đọc tên. Tiếp theo làm ...


3
Tôi có thể nói rằng bạn đang nói đùa, nhưng điều đáng sợ là một số người có thể chỉ xem xét lời khuyên của bạn một cách nghiêm túc!
Daniel Pryden

Đồng ý với Daniel. Tôi yêu thích sự hoài nghi nhiều như vậy - nó không nên được sử dụng trong tài liệu kỹ thuật, sách trắng và các trang Hỏi & Đáp như SO.
cfi

1
-1 cho một câu trả lời hoài nghi, không nhất thiết phải được người mới bắt đầu nhận ra.
Johan Bezem
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.