Trong lập trình chức năng, làm thế nào để đạt được mô đun hóa thông qua các định luật toán học?


11

Tôi đọc trong câu hỏi này rằng các lập trình viên chức năng có xu hướng sử dụng các bằng chứng toán học để đảm bảo rằng chương trình của họ hoạt động chính xác. Điều này nghe có vẻ dễ dàng và nhanh hơn so với thử nghiệm đơn vị, nhưng đến từ nền tảng Kiểm tra đơn vị / OOP mà tôi chưa từng thấy.

Bạn có thể giải thích cho tôi và cho tôi một ví dụ?


7
"Điều này nghe có vẻ dễ dàng và nhanh hơn so với thử nghiệm đơn vị". Vâng, âm thanh. Trong thực tế, hầu hết các phần mềm là không thể. Và tại sao tiêu đề đề cập đến tính mô-đun nhưng bạn đang nói về xác minh?
Euphoric

@Euphoric Trong Kiểm tra đơn vị trong OOP, bạn viết các bài kiểm tra để xác minh ... xác minh rằng một phần của phần mềm đang hoạt động chính xác, nhưng cũng xác minh rằng các mối quan tâm của bạn được tách ra ... tức là mô đun hóa và tái sử dụng ... nếu tôi hiểu chính xác.
leeand00

2
@Euphoric Chỉ khi bạn lạm dụng đột biến và kế thừa và làm việc trong các ngôn ngữ có hệ thống loại thiếu sót (nghĩa là có null).
Doval

@ leeand00 Tôi nghĩ bạn đang sử dụng sai thuật ngữ "xác minh". Tính mô đun và khả năng sử dụng lại không được kiểm tra trực tiếp bằng xác minh phần mềm (mặc dù, tất nhiên, việc thiếu tính mô đun có thể làm cho phần mềm khó bảo trì và tái sử dụng hơn, do đó đưa ra lỗi và không thực hiện được quy trình xác minh).
Andres F.

Sẽ dễ dàng hơn nhiều để xác minh các phần của phần mềm nếu nó được viết theo cách mô đun. Vì vậy, bạn có thể có bằng chứng thực tế rằng chức năng hoạt động chính xác cho một số chức năng, đối với những người khác bạn có thể viết bài kiểm tra đơn vị.
grizwako

Câu trả lời:


22

Một bằng chứng khó hơn nhiều trong thế giới OOP vì các tác dụng phụ, thừa kế không hạn chế và nulllà thành viên của mọi loại. Hầu hết các bằng chứng đều dựa trên một nguyên tắc cảm ứng để cho thấy rằng bạn đã đề cập đến mọi khả năng và cả 3 điều đó khiến việc chứng minh trở nên khó khăn hơn.

Giả sử chúng ta đang triển khai các cây nhị phân có chứa các giá trị nguyên (để giữ cho cú pháp đơn giản hơn, tôi sẽ không đưa lập trình chung vào điều này, mặc dù nó sẽ không thay đổi bất cứ điều gì.) Trong ML chuẩn, tôi sẽ định nghĩa như thế điều này:

datatype tree = Empty | Node of (tree * int * tree)

Điều này giới thiệu một loại mới gọi là treegiá trị của chúng có thể có chính xác hai loại (hoặc các lớp, không bị nhầm lẫn với khái niệm OOP của một lớp) - một Emptygiá trị không mang thông tin và Nodecác giá trị mang 3-tuple có giá trị đầu tiên và cuối cùng các phần tử là trees và có phần tử ở giữa là một int. Giá trị gần đúng nhất của tuyên bố này trong OOP sẽ giống như thế này:

public class Tree {
    private Tree() {} // Prevent external subclassing

    public static final class Empty extends Tree {}

    public static final class Node extends Tree {
        public final Tree leftChild;
        public final int value;
        public final Tree rightChild;

        public Node(Tree leftChild, int value, Tree rightChild) {
            this.leftChild = leftChild;
            this.value = value;
            this.rightChild = rightChild;
        }
    }
}

Với sự cảnh báo rằng các biến của loại Cây không bao giờ có thể null.

Bây giờ chúng ta hãy viết một hàm để tính chiều cao (hoặc độ sâu) của cây và giả sử chúng ta có quyền truy cập vào một maxhàm trả về số lớn hơn của hai số:

fun height(Empty) =
        0
 |  height(Node (leftChild, value, rightChild)) =
        1 + max( height(leftChild), height(rightChild) )

Chúng ta đã định nghĩa heighthàm theo các trường hợp - có một định nghĩa cho Emptycây và một định nghĩa cho Nodecây. Trình biên dịch biết có bao nhiêu lớp cây tồn tại và sẽ đưa ra cảnh báo nếu bạn không xác định cả hai trường hợp. Các biểu hiện Node (leftChild, value, rightChild)trong chữ ký chức năng liên kết với các giá trị của 3-tuple các biến leftChild, valuerightChildtương ứng vì vậy chúng tôi có thể tham khảo chúng trong định nghĩa hàm. Nó giống như đã khai báo các biến cục bộ như thế này bằng ngôn ngữ OOP:

Tree leftChild = tuple.getFirst();
int value = tuple.getSecond();
Tree rightChild = tuple.getThird();

Làm thế nào chúng ta có thể chứng minh chúng ta đã thực hiện heightđúng? Chúng ta có thể sử dụng cảm ứng cấu trúc , bao gồm: 1. Chứng minh heightlà đúng trong trường hợp cơ sở của treeloại ( Empty) 2. Giả sử rằng các cuộc gọi đệ quy heightlà chính xác, chứng minh rằng đó heightlà chính xác cho trường hợp không phải là cơ sở ) (khi cây thực sự là a Node).

Đối với bước 1, chúng ta có thể thấy rằng hàm luôn trả về 0 khi đối số là một Emptycây. Điều này đúng theo định nghĩa về chiều cao của cây.

Đối với bước 2, hàm trả về 1 + max( height(leftChild), height(rightChild) ). Giả sử rằng các cuộc gọi đệ quy thực sự trả lại chiều cao của trẻ em, chúng ta có thể thấy rằng điều này cũng đúng.

Và điều đó hoàn thành bằng chứng. Bước 1 và 2 kết hợp tất cả các khả năng. Tuy nhiên, lưu ý rằng chúng tôi không có đột biến, không có giá trị và có chính xác hai loại cây. Bỏ đi ba điều kiện đó và bằng chứng nhanh chóng trở nên phức tạp hơn, nếu không thực tế.


EDIT: Vì câu trả lời này đã tăng lên hàng đầu, tôi muốn thêm một ví dụ ít tầm thường hơn về một bằng chứng và bao gồm cảm ứng cấu trúc kỹ lưỡng hơn một chút. Ở trên chúng tôi đã chứng minh rằng nếu heighttrả về , giá trị trả về của nó là chính xác. Chúng tôi đã không chứng minh rằng nó luôn trả về một giá trị, mặc dù. Chúng ta cũng có thể sử dụng cảm ứng cấu trúc để chứng minh điều này (hoặc bất kỳ tài sản nào khác.) Một lần nữa, trong bước 2, chúng ta được phép đảm nhận quyền sở hữu của các cuộc gọi đệ quy miễn là tất cả các cuộc gọi đệ quy hoạt động trên một con trực tiếp của cây.

Một hàm có thể không trả về giá trị trong hai tình huống: nếu nó ném ngoại lệ và nếu nó lặp lại mãi mãi. Trước tiên, hãy chứng minh rằng nếu không có ngoại lệ nào được đưa ra, hàm sẽ chấm dứt:

  1. Chứng minh rằng (nếu không có ngoại lệ nào được ném) thì hàm kết thúc cho các trường hợp cơ sở ( Empty). Vì chúng tôi trả về vô điều kiện 0, nó chấm dứt.

  2. Chứng minh rằng hàm kết thúc trong các trường hợp không phải là cơ sở ( Node). Có ba chức năng cuộc gọi ở đây: +, max, và height. Chúng tôi biết điều đó +maxchấm dứt vì chúng là một phần của thư viện tiêu chuẩn của ngôn ngữ và chúng được xác định theo cách đó. Như đã đề cập trước đó, chúng tôi được phép giả định tài sản mà chúng tôi đang cố chứng minh là đúng đối với các cuộc gọi đệ quy miễn là chúng hoạt động trên các cây con ngay lập tức, do đó, các cuộc gọi heightcũng chấm dứt.

Điều đó kết luận bằng chứng. Lưu ý rằng bạn sẽ không thể chứng minh chấm dứt bằng một bài kiểm tra đơn vị. Bây giờ tất cả những gì còn lại là để cho thấy rằng heightkhông ném ngoại lệ.

  1. Chứng minh rằng heightkhông ném ngoại lệ vào trường hợp cơ sở ( Empty). Trở về 0 không thể ném ngoại lệ, vậy là xong.
  2. Chứng minh rằng heightkhông ném ngoại lệ vào trường hợp không có cơ sở ( Node). Giả sử một lần nữa rằng chúng ta biết +maxkhông ném ngoại lệ. Và cảm ứng cấu trúc cho phép chúng ta giả sử các cuộc gọi đệ quy sẽ không ném (vì hoạt động trên con ngay lập tức của cây.) Nhưng chờ đã! Hàm này là đệ quy, nhưng không đệ quy đuôi . Chúng ta có thể thổi bay đống! Bằng chứng cố gắng của chúng tôi đã phát hiện ra một lỗi. Chúng ta có thể sửa nó bằng cách thay đổi heightthành đệ quy đuôi .

Tôi hy vọng điều này cho thấy bằng chứng không phải đáng sợ hay phức tạp. Trên thực tế, bất cứ khi nào bạn viết mã, bạn đã xây dựng một bằng chứng trong đầu một cách không chính thức (nếu không, bạn sẽ không tin rằng bạn vừa thực hiện chức năng.) Bằng cách tránh null, đột biến không cần thiết và thừa kế không hạn chế, bạn có thể chứng minh trực giác của mình là sửa khá dễ. Những hạn chế này không khắc nghiệt như bạn nghĩ:

  • null là một lỗ hổng ngôn ngữ và làm cho nó đi là tốt vô điều kiện.
  • Đột biến đôi khi không thể tránh khỏi và cần thiết, nhưng nó cần ít thường xuyên hơn bạn nghĩ - đặc biệt là khi bạn có cấu trúc dữ liệu liên tục.
  • Đối với việc có số lượng lớp hữu hạn (theo nghĩa chức năng) / lớp con (theo nghĩa OOP) so với số lượng không giới hạn trong số chúng, đó là một chủ đề quá lớn cho một câu trả lời . Đủ để nói rằng có một sự đánh đổi thiết kế ngoài đó - khả năng chính xác và tính linh hoạt của việc gia hạn.

8
  1. Lý do về mã dễ dàng hơn nhiều khi mọi thứ đều bất biến . Kết quả là, các vòng lặp thường được viết là đệ quy. Nói chung, việc xác minh tính chính xác của giải pháp đệ quy sẽ dễ dàng hơn. Thông thường, một giải pháp như vậy cũng sẽ đọc rất giống với một định nghĩa toán học của vấn đề.

    Tuy nhiên, có rất ít động lực để thực hiện một bằng chứng chính thức thực sự về tính đúng đắn trong hầu hết các trường hợp. Bằng chứng là khó khăn, mất nhiều thời gian (con người) và có ROI thấp.

  2. Một số ngôn ngữ chức năng (đặc biệt từ gia đình ML) có hệ thống loại cực kỳ biểu cảm có thể đảm bảo hoàn thiện hơn nhiều rằng hệ thống kiểu C (nhưng một số ý tưởng như khái quát cũng trở nên phổ biến trong các ngôn ngữ chính). Khi một chương trình vượt qua kiểm tra loại, đây là một loại bằng chứng tự động. Trong một số trường hợp, điều này sẽ có thể phát hiện một số lỗi (ví dụ: quên trường hợp cơ sở trong đệ quy hoặc quên xử lý một số trường hợp nhất định trong khớp mẫu).

    Mặt khác, các hệ thống loại này phải được giữ rất hạn chế để giữ cho chúng có thể quyết định được . Vì vậy, theo một nghĩa nào đó, chúng tôi có được sự bảo đảm tĩnh bằng cách từ bỏ tính linh hoạt - và những hạn chế này là lý do tại sao các bài báo học thuật phức tạp dọc theo dòng của Một giải pháp đơn phương cho một vấn đề được giải quyết, trong Haskellith tồn tại.

    Tôi thích cả hai ngôn ngữ rất tự do, và các ngôn ngữ rất hạn chế, và cả hai đều có những khó khăn tương ứng. Nhưng đó không phải là trường hợp mà một người sẽ giỏi hơn, mỗi người sẽ thuận tiện hơn cho một loại nhiệm vụ khác nhau.

Sau đó, phải chỉ ra rằng bằng chứng và thử nghiệm đơn vị không thể thay thế cho nhau. Cả hai đều cho phép chúng tôi đặt giới hạn về tính chính xác của chương trình:

  • Thử nghiệm đặt giới hạn trên về tính chính xác: Nếu thử nghiệm thất bại, chương trình không chính xác, nếu không có thử nghiệm thất bại, chúng tôi chắc chắn rằng chương trình sẽ xử lý các trường hợp được thử nghiệm, nhưng vẫn có thể có các lỗi chưa được phát hiện.

    int factorial(int n) {
      if (n <= 1) return 1;
      if (n == 2) return 2;
      if (n == 3) return 6;
      return -1;
    }
    
    assert(factorial(0) == 1);
    assert(factorial(1) == 1);
    assert(factorial(3) == 6);
    // oops, we forgot to test that it handles n > 3…
    
  • Bằng chứng đặt giới hạn thấp hơn về tính đúng đắn: Có thể không thể chứng minh các thuộc tính nhất định. Ví dụ, có thể dễ dàng chứng minh rằng một hàm luôn trả về một số (đó là những gì hệ thống loại làm). Nhưng có thể không thể chứng minh rằng con số sẽ luôn như vậy < 10.

    int factorial(int n) {
      return n;  // FIXME this is just a placeholder to make it compile
    }
    
    // type system says this will be OK…
    

1
"Có thể không thể chứng minh một số tính chất nhất định ... Nhưng có thể không thể chứng minh rằng số đó sẽ luôn là <10." Nếu tính chính xác của chương trình phụ thuộc vào số lượng nhỏ hơn 10, bạn sẽ có thể chứng minh điều đó. Đúng là hệ thống loại không thể (ít nhất là không loại trừ hàng tấn chương trình hợp lệ) - nhưng bạn có thể.
Doval

@Doval Vâng. Tuy nhiên, hệ thống loại chỉ là một ví dụ về hệ thống để chứng minh. Các hệ thống loại rất hạn chế rõ ràng và không thể đánh giá sự thật của các tuyên bố nhất định. Một người có thể thực hiện các bằng chứng phức tạp hơn nhiều, nhưng vẫn sẽ bị giới hạn trong những gì anh ta có thể chứng minh. Vẫn còn một giới hạn không thể vượt qua, nó chỉ còn xa hơn.
amon

1
Đồng ý, tôi chỉ nghĩ rằng ví dụ này là một chút sai lệch.
Doval

2
Trong các ngôn ngữ được gõ phụ thuộc, như Idris, thậm chí có thể chứng minh rằng nó trả về thấp hơn 10.
Ingo

2
Có lẽ cách tốt hơn để giải quyết mối quan tâm mà @Doval đưa ra là nói rằng một số vấn đề là không thể giải quyết được (ví dụ như vấn đề tạm dừng), đòi hỏi quá nhiều thời gian để chứng minh, hoặc sẽ cần khám phá toán học mới để chứng minh kết quả. Ý kiến ​​cá nhân của tôi là bạn nên làm rõ rằng nếu một cái gì đó được chứng minh là đúng, thì không cần phải kiểm tra đơn vị đó. Bằng chứng đã đặt một giới hạn trên và dưới. Lý do tại sao các bằng chứng và bài kiểm tra không thể thay thế cho nhau là vì một bằng chứng có thể quá khó để thực hiện hoặc không thể thực hiện được. Ngoài ra các bài kiểm tra có thể được tự động (khi mã thay đổi).
Thomas Eding

7

Một lời cảnh báo có thể theo thứ tự ở đây:

Mặc dù nói chung đúng như những gì người khác viết ở đây - nói tóm lại, các hệ thống loại tiên tiến, tính bất biến và tính minh bạch tham chiếu đóng góp rất nhiều cho tính chính xác - không phải là trường hợp thử nghiệm không được thực hiện trong thế giới chức năng. Ngược lại !

Điều này là do chúng tôi có các công cụ như Quickcheck, tạo ra các trường hợp thử nghiệm một cách tự động và ngẫu nhiên. Bạn chỉ nêu ra các luật mà một chức năng phải tuân theo, và sau đó quickcheck sẽ kiểm tra các luật này cho hàng trăm trường hợp kiểm tra ngẫu nhiên.

Bạn thấy đấy, đây là một mức độ cao hơn một chút so với kiểm tra bình đẳng tầm thường trên một số ít các trường hợp thử nghiệm.

Dưới đây là một ví dụ từ việc triển khai cây AVL:

--- A generator for arbitrary Trees with integer keys and string values
aTree = arbitrary :: Gen (Tree Int String)


--- After insertion, a lookup with the same key yields the inserted value        
p_insert = forAll aTree (\t -> 
             forAll arbitrary (\k ->
               forAll arbitrary (\v ->
                lookup (insert t k v) k == Just v)))

--- After deletion of a key, lookup results in Nothing
p_delete = forAll aTree (\t ->
            not (null t) ==> forAll (elements (keys t)) (\k ->
                lookup (delete t k) k == Nothing))

Định luật thứ hai (hoặc thuộc tính) chúng ta có thể đọc như sau: Đối với tất cả các cây tùy ý t, các trường hợp sau: nếu tkhông trống, thì đối với tất cả các khóa kcủa cây đó, nó sẽ giữ việc tìm kiếm ktrong cây đó là kết quả của việc xóa ktừ t, kết quả sẽ là Nothing(chỉ ra: không tìm thấy).

Điều này kiểm tra chức năng thích hợp để xóa một khóa hiện có. Những luật nào sẽ chi phối việc xóa một khóa không tồn tại? Chúng tôi chắc chắn muốn cây kết quả giống như cây chúng tôi đã xóa. Chúng ta có thể diễn đạt điều này một cách dễ dàng:

p_delete_nonexistant = forAll aTree (\t ->
                          forAll arbitrary (\k -> 
                              k `notElem` keys t ==> delete t k == t))

Bằng cách này, thử nghiệm là thực sự thú vị. Và bên cạnh đó, một khi bạn học cách đọc thuộc tính quickcheck, chúng sẽ đóng vai trò là một đặc tả có thể kiểm tra bằng máy .


4

Tôi không hiểu chính xác câu trả lời được liên kết có nghĩa là gì "đạt được tính mô đun thông qua các định luật toán học", nhưng tôi nghĩ rằng tôi có một ý tưởng về ý nghĩa của nó.

Kiểm tra Functor :

Lớp Functor được định nghĩa như thế này:

 class Functor f where
   fmap :: (a -> b) -> f a -> f b

Nó không đi kèm với các trường hợp thử nghiệm, nhưng thay vào đó, với một vài điều luật phải được thỏa mãn.

Tất cả các trường hợp của Functor nên tuân theo:

 fmap id = id
 fmap (p . q) = (fmap p) . (fmap q)

Bây giờ hãy nói rằng bạn thực hiện Functor( nguồn ):

instance  Functor Maybe  where
    fmap _ Nothing       = Nothing
    fmap f (Just a)      = Just (f a)

Vấn đề là để xác minh rằng việc thực hiện của bạn đáp ứng các luật. Làm thế nào để bạn đi về làm điều đó?

Một cách tiếp cận là viết các trường hợp thử nghiệm. Hạn chế cơ bản của phương pháp này là bạn đang xác minh hành vi trong một số trường hợp hữu hạn (chúc may mắn kiểm tra triệt để một chức năng với 8 tham số!), Và vì vậy việc vượt qua các bài kiểm tra không thể đảm bảo bất cứ điều gì ngoài việc các bài kiểm tra vượt qua.

Một cách tiếp cận khác là sử dụng lý luận toán học, tức là một bằng chứng, dựa trên định nghĩa thực tế (thay vì dựa trên hành vi trong một số trường hợp hạn chế). Ý tưởng ở đây là một bằng chứng toán học có thể hiệu quả hơn; tuy nhiên, điều này phụ thuộc vào mức độ dễ hiểu của chương trình của bạn đối với bằng chứng toán học.

Tôi không thể hướng dẫn bạn qua một bằng chứng chính thức thực tế rằng Functortrường hợp trên thỏa mãn các luật, nhưng tôi sẽ thử và đưa ra một phác thảo về bằng chứng có thể trông như thế nào:

  1. fmap id = id
    • nếu chúng ta có Nothing
      • fmap id Nothing= Nothingbởi phần 1 của việc thực hiện
      • id Nothing= Nothingtheo định nghĩa củaid
    • nếu chúng ta có Just x
      • fmap id (Just x)= Just (id x)= Just xbởi phần 2 của việc thực hiện, sau đó theo định nghĩa củaid
  2. fmap (p . q) = (fmap p) . (fmap q)
    • nếu chúng ta có Nothing
      • fmap (p . q) Nothing= Nothingbởi phần 1
      • (fmap p) . (fmap q) $ Nothing= (fmap p) $ Nothing= Nothingbởi hai ứng dụng của phần 1
    • nếu chúng ta có Just x
      • fmap (p . q) (Just x)= Just ((p . q) x)= Just (p (q x))bởi phần 2, sau đó theo định nghĩa của.
      • (fmap p) . (fmap q) $ (Just x)= (fmap p) $ (Just (q x))= Just (p (q x))bởi hai ứng dụng của phần hai

-1

"Hãy coi chừng các lỗi trong đoạn mã trên; tôi chỉ chứng minh nó đúng, không thử nó." - Donald Knuth

Trong một thế giới hoàn hảo, các lập trình viên rất hoàn hảo và không mắc lỗi, vì vậy không có lỗi.

Trong một thế giới hoàn hảo, các nhà khoa học máy tính và nhà toán học cũng rất hoàn hảo và cũng không mắc sai lầm.

Nhưng chúng ta không sống trong một thế giới hoàn hảo. Vì vậy, chúng tôi không thể dựa vào các lập trình viên để không phạm sai lầm. Nhưng chúng ta không thể cho rằng bất kỳ nhà khoa học máy tính nào đưa ra bằng chứng toán học rằng chương trình là chính xác đều không phạm sai lầm nào trong chứng minh đó. Vì vậy, tôi sẽ không chú ý đến bất cứ ai cố gắng chứng minh rằng mã của mình hoạt động. Viết kiểm tra đơn vị và cho tôi thấy rằng mã hoạt động theo thông số kỹ thuật. Bất cứ điều gì khác sẽ không thuyết phục tôi về bất cứ điều gì.


5
Bài kiểm tra đơn vị có thể có sai lầm quá. Quan trọng hơn, các xét nghiệm chỉ có thể cho thấy sự hiện diện của lỗi - không bao giờ có sự vắng mặt của chúng. Như @Ingo đã nói trong câu trả lời của anh ấy, họ thực hiện kiểm tra sự tỉnh táo tuyệt vời và bổ sung bằng chứng độc đáo, nhưng chúng không phải là sự thay thế cho họ.
Doval
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.