Gõ phụ thuộc Haskell, bây giờ?
Haskell, ở một mức độ nhỏ, là một ngôn ngữ được gõ phụ thuộc. Có một khái niệm về dữ liệu cấp loại, giờ đây được nhập một cách hợp lý hơn nhờ vào DataKinds
, và có một số phương tiện ( GADTs
) để đưa ra biểu diễn thời gian chạy cho dữ liệu cấp loại. Do đó, các giá trị của công cụ thời gian chạy hiển thị một cách hiệu quả trong các loại , đó là ý nghĩa của một ngôn ngữ được gõ một cách phụ thuộc.
Các kiểu dữ liệu đơn giản được thăng cấp đến mức loại, do đó các giá trị mà chúng chứa có thể được sử dụng trong các loại. Do đó, ví dụ điển hình
data Nat = Z | S Nat
data Vec :: Nat -> * -> * where
VNil :: Vec Z x
VCons :: x -> Vec n x -> Vec (S n) x
trở nên có thể, và với nó, các định nghĩa như
vApply :: Vec n (s -> t) -> Vec n s -> Vec n t
vApply VNil VNil = VNil
vApply (VCons f fs) (VCons s ss) = VCons (f s) (vApply fs ss)
cái gì là tốt. Lưu ý rằng độ dài n
là một thứ hoàn toàn tĩnh trong hàm đó, đảm bảo rằng các vectơ đầu vào và đầu ra có cùng độ dài, mặc dù độ dài đó không có vai trò gì trong việc thực hiện
vApply
. Ngược lại, nó nhiều phức tạp hơn (ví dụ, không thể) để thực hiện các chức năng mà làm cho n
các bản sao của một định x
(đó sẽ là pure
để vApply
's <*>
)
vReplicate :: x -> Vec n x
bởi vì điều quan trọng là phải biết có bao nhiêu bản sao để tạo ra trong thời gian chạy. Nhập đơn.
data Natty :: Nat -> * where
Zy :: Natty Z
Sy :: Natty n -> Natty (S n)
Đối với bất kỳ loại có thể quảng bá nào, chúng ta có thể xây dựng họ singleton, được lập chỉ mục cho loại được quảng cáo, có các bản sao thời gian chạy của các giá trị của nó. Natty n
là loại bản sao thời gian chạy của cấp độ loại n
:: Nat
. Bây giờ chúng ta có thể viết
vReplicate :: Natty n -> x -> Vec n x
vReplicate Zy x = VNil
vReplicate (Sy n) x = VCons x (vReplicate n x)
Vì vậy, ở đó bạn có một giá trị cấp độ được chuyển thành giá trị thời gian chạy: kiểm tra bản sao thời gian chạy tinh chỉnh kiến thức tĩnh của giá trị cấp độ loại. Mặc dù các thuật ngữ và loại được tách ra, chúng ta có thể làm việc theo cách gõ phụ thuộc bằng cách sử dụng cấu trúc đơn như một loại nhựa epoxy, tạo liên kết giữa các pha. Đó là một chặng đường dài từ việc cho phép các biểu thức thời gian chạy tùy ý trong các loại, nhưng nó không là gì cả.
Khó chịu là gì? Cái gì còn thiếu?
Hãy tạo một chút áp lực cho công nghệ này và xem những gì bắt đầu chao đảo. Chúng ta có thể có ý tưởng rằng các singletons nên được quản lý một cách ngầm định hơn một chút
class Nattily (n :: Nat) where
natty :: Natty n
instance Nattily Z where
natty = Zy
instance Nattily n => Nattily (S n) where
natty = Sy natty
cho phép chúng tôi viết, nói,
instance Nattily n => Applicative (Vec n) where
pure = vReplicate natty
(<*>) = vApply
Điều đó hoạt động, nhưng bây giờ có nghĩa là Nat
loại ban đầu của chúng tôi đã sinh ra ba bản sao: một loại, một gia đình độc thân và một lớp đơn. Chúng tôi có một quy trình khá lộn xộn để trao đổi Natty n
các giá trị và Nattily n
từ điển rõ ràng . Hơn nữa, Natty
không phải là Nat
: chúng ta có một số loại phụ thuộc vào các giá trị thời gian chạy, nhưng không phải ở loại mà chúng ta nghĩ đến đầu tiên. Không có ngôn ngữ gõ hoàn toàn phụ thuộc làm cho loại phụ thuộc này phức tạp!
Trong khi đó, mặc dù Nat
có thể được thăng chức, Vec
không thể. Bạn không thể lập chỉ mục theo loại được lập chỉ mục. Hoàn toàn dựa trên các ngôn ngữ được gõ phụ thuộc không có giới hạn nào như vậy, và trong sự nghiệp của tôi là một cuộc phô trương phụ thuộc, tôi đã học cách đưa các ví dụ về lập chỉ mục hai lớp trong các cuộc nói chuyện của mình, chỉ để dạy cho những người đã lập chỉ mục một lớp khó nhưng có thể không mong đợi tôi xếp lại như một ngôi nhà của những lá bài. Có vấn đề gì vậy? Bình đẳng. Các GADT hoạt động bằng cách dịch các ràng buộc mà bạn đạt được hoàn toàn khi bạn cung cấp cho một hàm tạo một kiểu trả về cụ thể thành các yêu cầu phương trình rõ ràng. Như thế này.
data Vec (n :: Nat) (x :: *)
= n ~ Z => VNil
| forall m. n ~ S m => VCons x (Vec m x)
Trong mỗi hai phương trình của chúng tôi, cả hai bên đều có loại Nat
.
Bây giờ hãy thử bản dịch tương tự cho một cái gì đó được lập chỉ mục trên các vectơ.
data InVec :: x -> Vec n x -> * where
Here :: InVec z (VCons z zs)
After :: InVec z ys -> InVec z (VCons y ys)
trở thành
data InVec (a :: x) (as :: Vec n x)
= forall m z (zs :: Vec x m). (n ~ S m, as ~ VCons z zs) => Here
| forall m y z (ys :: Vec x m). (n ~ S m, as ~ VCons y ys) => After (InVec z ys)
và bây giờ chúng ta hình thành các ràng buộc phương trình giữa as :: Vec n x
và
VCons z zs :: Vec (S m) x
nơi hai bên có các loại khác nhau về mặt cú pháp (nhưng có thể chứng minh bằng nhau). Lõi GHC hiện không được trang bị cho một khái niệm như vậy!
Còn thiếu gì nữa không? Chà, hầu hết Haskell bị thiếu ở cấp độ loại. Ngôn ngữ của các thuật ngữ mà bạn có thể quảng bá chỉ có các biến và các hàm tạo không phải GADT, thực sự. Khi bạn đã có những thứ đó, type family
máy móc cho phép bạn viết các chương trình cấp độ: một số trong số đó có thể khá giống các chức năng bạn sẽ xem xét viết ở cấp độ thuật ngữ (ví dụ: trang bị Nat
thêm, vì vậy bạn có thể đưa ra một loại tốt để nối thêm Vec
) , nhưng đó chỉ là sự trùng hợp!
Một điều còn thiếu, trong thực tế, là một thư viện sử dụng các khả năng mới của chúng tôi để lập chỉ mục các loại theo các giá trị. Làm gì Functor
và Monad
trở thành trong thế giới mới dũng cảm này? Tôi đang nghĩ về nó, nhưng vẫn còn nhiều việc phải làm.
Chạy các chương trình cấp độ
Haskell, giống như hầu hết các ngôn ngữ lập trình được gõ phụ thuộc, có hai
ngữ nghĩa hoạt động. Có cách hệ thống thời gian chạy chạy các chương trình (chỉ biểu thức đóng, sau khi xóa kiểu, được tối ưu hóa cao) và sau đó là cách trình đánh máy chạy chương trình (gia đình kiểu của bạn, "Prolog lớp loại" của bạn, với biểu thức mở). Đối với Haskell, bạn thường không kết hợp cả hai, vì các chương trình đang được thực thi bằng các ngôn ngữ khác nhau. Các ngôn ngữ được gõ phụ thuộc có các mô hình thực thi tĩnh và thời gian chạy riêng biệt cho cùng một ngôn ngữ của các chương trình, nhưng đừng lo, mô hình thời gian chạy vẫn cho phép bạn thực hiện xóa và thực sự, xóa bằng chứng: đó là trích xuất của Coqcơ chế mang lại cho bạn; đó ít nhất là những gì trình biên dịch của Edwin Brady thực hiện (mặc dù Edwin xóa các giá trị trùng lặp không cần thiết, cũng như các loại và bằng chứng). Sự phân biệt pha có thể không còn là sự phân biệt của thể loại cú pháp
nữa, nhưng nó vẫn sống và tốt.
Các ngôn ngữ được gõ một cách phụ thuộc, là tổng số, cho phép người đánh máy chạy các chương trình miễn phí khỏi nỗi sợ bất cứ điều gì tồi tệ hơn là chờ đợi lâu. Khi Haskell trở nên phụ thuộc nhiều hơn, chúng ta phải đối mặt với câu hỏi mô hình thực thi tĩnh của nó sẽ là gì? Một cách tiếp cận có thể là hạn chế thực thi tĩnh đối với các hàm tổng, cho phép chúng ta có cùng quyền tự do chạy, nhưng có thể buộc chúng ta tạo ra sự khác biệt (ít nhất là đối với mã cấp độ loại) giữa dữ liệu và codata, để chúng ta có thể biết liệu có thực thi chấm dứt hoặc năng suất. Nhưng đó không phải là cách tiếp cận duy nhất. Chúng tôi có thể tự do lựa chọn một mô hình thực thi yếu hơn nhiều, miễn cưỡng chạy các chương trình, với chi phí tạo ra ít phương trình được đưa ra chỉ bằng tính toán. Và thực tế, đó là những gì GHC thực sự làm. Các quy tắc gõ cho lõi GHC không đề cập đến việc chạy
chương trình, nhưng chỉ để kiểm tra bằng chứng cho phương trình. Khi dịch vào lõi, bộ giải ràng buộc của GHC cố chạy các chương trình cấp loại của bạn, tạo ra một dấu vết nhỏ bằng chứng cho thấy một biểu thức đã cho bằng với dạng thông thường của nó. Phương pháp tạo bằng chứng này hơi khó đoán và không thể tránh khỏi hoàn toàn: chẳng hạn, nó chống lại sự đệ quy trông đáng sợ, và điều đó có lẽ là khôn ngoan. Một điều chúng ta không cần phải lo lắng là việc thực hiện các IO
tính toán trong máy đánh chữ: hãy nhớ rằng máy đánh chữ không phải mang
launchMissiles
ý nghĩa tương tự như hệ thống thời gian chạy!
Văn hóa Hindley-Milner
Hệ thống loại Hindley-Milner đạt được sự trùng hợp thực sự tuyệt vời của bốn sự khác biệt, với tác dụng phụ đáng tiếc về mặt văn hóa mà nhiều người không thể thấy sự khác biệt giữa sự khác biệt và cho rằng sự trùng hợp là không thể tránh khỏi! Tôi đang nói về cái gì vậy?
- điều khoản vs loại
- những điều được viết một cách rõ ràng vs điều ngầm bằng văn bản
- hiện diện tại thời gian chạy vs tẩy xoá trước khi thời gian chạy
- trừu tượng không phụ thuộc so với định lượng phụ thuộc
Chúng ta thường viết các thuật ngữ và để các loại được suy luận ... và sau đó bị xóa. Chúng ta thường sử dụng để định lượng các biến loại với sự trừu tượng hóa và ứng dụng loại tương ứng diễn ra âm thầm và tĩnh.
Bạn không cần phải rẽ quá xa vani Hindley-Milner trước những khác biệt ra khỏi sự liên kết, và đó là không có điều xấu . Để bắt đầu, chúng ta có thể có nhiều loại thú vị hơn nếu chúng ta sẵn sàng viết chúng ở một vài nơi. Trong khi đó, chúng ta không phải viết từ điển lớp loại khi chúng ta sử dụng các hàm quá tải, nhưng những từ điển đó chắc chắn có mặt (hoặc nội tuyến) vào thời gian chạy. Trong các ngôn ngữ được gõ phụ thuộc, chúng tôi hy vọng sẽ xóa nhiều hơn chỉ các loại trong thời gian chạy, nhưng (như với các loại loại) rằng một số giá trị được suy luận ngầm sẽ không bị xóa. Ví dụ, vReplicate
đối số số thường được suy ra từ loại vectơ mong muốn, nhưng chúng ta vẫn cần biết nó vào thời gian chạy.
Những lựa chọn thiết kế ngôn ngữ nào chúng ta nên xem xét bởi vì những sự trùng hợp này không còn giữ được nữa? Ví dụ, có đúng là Haskell không cung cấp cách nào để khởi tạo một bộ forall x. t
định lượng một cách rõ ràng không? Nếu người đánh máy không thể đoán x
bằng cách unifiying t
, chúng ta không có cách nào khác để nói những gì x
phải được.
Rộng hơn, chúng ta không thể coi "suy luận kiểu" là một khái niệm nguyên khối mà chúng ta có hoặc không có gì cả. Để bắt đầu, chúng ta cần tách ra khía cạnh "khái quát hóa" (quy tắc "cho phép" của Milner), điều này phụ thuộc rất nhiều vào việc hạn chế loại nào tồn tại để đảm bảo rằng một cỗ máy ngu ngốc có thể đoán được, từ khía cạnh "chuyên môn hóa" (var "của Milner "Quy tắc) có hiệu quả như người giải quyết ràng buộc của bạn. Chúng ta có thể hy vọng rằng các loại cấp cao nhất sẽ trở nên khó suy luận hơn, nhưng thông tin loại nội bộ sẽ vẫn khá dễ truyền bá.
Các bước tiếp theo cho Haskell
Chúng ta đang thấy các loại và mức độ phát triển rất giống nhau (và họ đã chia sẻ một đại diện nội bộ trong GHC). Chúng tôi cũng có thể hợp nhất chúng. Sẽ rất vui * :: *
nếu chúng ta có thể: chúng ta đã mất âm thanh
logic từ lâu, khi chúng ta cho phép chạm đáy, nhưng âm thanh loại
thường là một yêu cầu yếu hơn. Chúng ta phải kiểm tra. Nếu chúng ta phải có các cấp độ loại, loại, vv khác nhau, ít nhất chúng ta có thể đảm bảo mọi thứ ở cấp độ trở lên luôn có thể được thăng cấp. Sẽ thật tuyệt vời khi sử dụng lại đa hình mà chúng ta đã có cho các loại, thay vì tái phát minh đa hình ở cấp độ loại.
Chúng ta nên đơn giản hóa và khái quát hóa hệ thống các ràng buộc hiện tại bằng cách cho phép các phương trình không đồng nhấta ~ b
trong đó các loại a
và
b
không giống nhau về mặt cú pháp (nhưng có thể được chứng minh bằng nhau). Đó là một kỹ thuật cũ (trong luận án của tôi, thế kỷ trước) khiến cho sự phụ thuộc dễ dàng hơn nhiều để đối phó. Chúng tôi có thể thể hiện các ràng buộc đối với các biểu thức trong GADT và do đó nới lỏng các hạn chế đối với những gì có thể được quảng bá.
Chúng ta nên loại bỏ sự cần thiết cho việc xây dựng đơn lẻ bằng cách giới thiệu một loại hàm phụ thuộc , pi x :: s -> t
. Một hàm có kiểu như vậy có thể được áp dụng rõ ràng cho bất kỳ biểu thức kiểu s
nào sống trong giao điểm của ngôn ngữ loại và thuật ngữ (vì vậy, các biến, hàm tạo, có nhiều hơn để đến sau). Ứng dụng và ứng dụng lambda tương ứng sẽ không bị xóa trong thời gian chạy, vì vậy chúng tôi có thể viết
vReplicate :: pi n :: Nat -> x -> Vec n x
vReplicate Z x = VNil
vReplicate (S n) x = VCons x (vReplicate n x)
mà không thay thế Nat
bởi Natty
. Miền của pi
có thể là bất kỳ loại có thể quảng bá nào, vì vậy nếu GADT có thể được quảng bá, chúng ta có thể viết các chuỗi định lượng phụ thuộc (hoặc "kính thiên văn" như de Briuijn gọi chúng)
pi n :: Nat -> pi xs :: Vec n x -> ...
đến bất cứ độ dài nào chúng ta cần.
Điểm của các bước này là loại bỏ sự phức tạp bằng cách làm việc trực tiếp với các công cụ chung hơn, thay vì thực hiện với các công cụ yếu và mã hóa cồng kềnh. Việc mua một phần hiện tại làm cho lợi ích của các loại phụ thuộc của Haskell đắt hơn mức cần thiết.
Quá khó?
Các loại phụ thuộc làm cho rất nhiều người lo lắng. Họ làm tôi lo lắng, nhưng tôi thích lo lắng, hoặc ít nhất tôi thấy khó mà không lo lắng. Nhưng điều đó không giúp ích gì cho việc có một màn sương mù vô minh như vậy xung quanh chủ đề này. Một số điều đó là do thực tế là tất cả chúng ta vẫn còn nhiều điều để học hỏi. Nhưng những người đề xuất các phương pháp ít triệt để hơn đã được biết là gây ra nỗi sợ về các loại phụ thuộc mà không luôn đảm bảo sự thật là hoàn toàn với họ. Tôi sẽ không đặt tên. Những "lỗi đánh máy không thể giải quyết được", "Turing không đầy đủ", "không phân biệt pha", "không xóa kiểu", "bằng chứng ở khắp mọi nơi", v.v., những huyền thoại vẫn tồn tại, mặc dù chúng là rác rưởi.
Đó chắc chắn không phải là trường hợp các chương trình gõ phụ thuộc phải luôn được chứng minh là đúng. Người ta có thể cải thiện vệ sinh cơ bản của các chương trình của một người, thực thi các bất biến bổ sung trong các loại mà không đi đến một đặc điểm kỹ thuật đầy đủ. Các bước nhỏ theo hướng này khá thường xuyên dẫn đến đảm bảo mạnh mẽ hơn nhiều với ít hoặc không có nghĩa vụ chứng minh bổ sung. Không phải sự thật là các chương trình gõ phụ thuộc chắc chắn có đầy đủ bằng chứng, thực sự tôi thường lấy sự hiện diện của bất kỳ bằng chứng nào trong mã của mình làm gợi ý cho câu hỏi định nghĩa của tôi .
Đối với, như với bất kỳ sự gia tăng trong khớp nối, chúng ta trở nên tự do để nói những điều mới lạ cũng như công bằng. Ví dụ: có rất nhiều cách hay để xác định cây tìm kiếm nhị phân, nhưng điều đó không có nghĩa là không có cách nào tốt . Điều quan trọng là không cho rằng những trải nghiệm xấu không thể được cải thiện, ngay cả khi nó làm cho bản ngã thừa nhận nó. Thiết kế các định nghĩa phụ thuộc là một kỹ năng mới cần học hỏi và trở thành lập trình viên Haskell không tự động biến bạn thành một chuyên gia! Và ngay cả khi một số chương trình bị phạm lỗi, tại sao bạn lại từ chối người khác tự do công bằng?
Tại sao vẫn còn phiền với Haskell?
Tôi thực sự thích các loại phụ thuộc, nhưng hầu hết các dự án hack của tôi vẫn ở Haskell. Tại sao? Haskell có các lớp loại. Haskell có các thư viện hữu ích. Haskell có một phương pháp điều trị khả thi (mặc dù không lý tưởng) về lập trình với các hiệu ứng. Haskell có một trình biên dịch sức mạnh công nghiệp. Các ngôn ngữ được gõ phụ thuộc ở giai đoạn sớm hơn trong cộng đồng và cơ sở hạ tầng đang phát triển, nhưng chúng ta sẽ đến đó, với sự thay đổi thế hệ thực sự trong những gì có thể, ví dụ, bằng cách tổng hợp siêu dữ liệu và tổng hợp kiểu dữ liệu. Nhưng bạn chỉ cần nhìn xung quanh những gì mọi người đang làm do các bước của Haskell đối với các loại phụ thuộc để thấy rằng có rất nhiều lợi ích để đạt được bằng cách đẩy thế hệ ngôn ngữ hiện tại về phía trước.