Mục đích của đơn nguyên người đọc là gì?


122

Đơn nguyên người đọc quá phức tạp và dường như vô dụng. Trong một ngôn ngữ mệnh lệnh như Java hoặc C ++, không có khái niệm tương đương cho đơn nguyên người đọc, nếu tôi không nhầm.

Bạn có thể cho tôi một ví dụ đơn giản và làm rõ điều này một chút không?


21
Bạn sử dụng đơn nguyên trình đọc nếu bạn muốn - thỉnh thoảng - đọc một số giá trị từ môi trường (không thể thay đổi), nhưng không muốn chuyển môi trường đó một cách rõ ràng. Trong Java hoặc C ++, bạn sẽ sử dụng các biến toàn cục (mặc dù nó không hoàn toàn giống nhau).
Daniel Fischer

5
@Daniel: Đó là âm thanh một awful nhiều như một câu trả lời
SingleNegationElimination

@TokenMacGuy Quá ngắn cho câu trả lời và bây giờ đã quá muộn để tôi nghĩ ra điều gì đó lâu hơn. Nếu không ai khác làm, tôi sẽ làm sau khi tôi đã ngủ.
Daniel Fischer

8
Trong Java hoặc C ++, đơn nguyên Reader sẽ tương tự với các tham số cấu hình được truyền cho một đối tượng trong phương thức khởi tạo của nó mà không bao giờ thay đổi trong suốt thời gian tồn tại của đối tượng. Trong Clojure, nó sẽ giống như một biến phạm vi động được sử dụng để tham số hóa hành vi của một hàm mà không cần chuyển nó một cách rõ ràng như một tham số.
danidiaz

Câu trả lời:


169

Đừng sợ! Đơn nguyên trình đọc thực sự không quá phức tạp và có tiện ích thực sự dễ sử dụng.

Có hai cách để tiếp cận một đơn nguyên: chúng ta có thể hỏi

  1. Đơn nguyên làm gì ? Nó được trang bị những thao tác gì? Nó tốt cho cái gì?
  2. Đơn nguyên được thực hiện như thế nào? Nó phát sinh từ đâu?

Từ cách tiếp cận đầu tiên, đơn nguyên của người đọc là một số loại trừu tượng

data Reader env a

như vậy mà

-- Reader is a monad
instance Monad (Reader env)

-- and we have a function to get its environment
ask :: Reader env env

-- finally, we can run a Reader
runReader :: Reader env a -> env -> a

Vậy chúng ta sử dụng cái này như thế nào? Chà, đơn nguyên của trình đọc rất tốt để chuyển thông tin cấu hình (ngầm định) thông qua một phép tính.

Bất cứ khi nào bạn có một "hằng số" trong một phép tính mà bạn cần ở nhiều điểm khác nhau, nhưng thực sự bạn muốn có thể thực hiện cùng một phép tính với các giá trị khác nhau, thì bạn nên sử dụng đơn nguyên đọc.

Các monads của trình đọc cũng được sử dụng để thực hiện những gì người OO gọi là tiêm phụ thuộc . Ví dụ: thuật toán negamax được sử dụng thường xuyên (trong các hình thức được tối ưu hóa cao) để tính toán giá trị của một vị trí trong trò chơi hai người chơi. Mặc dù vậy, bản thân thuật toán không quan tâm đến trò chơi mà bạn đang chơi, ngoại trừ việc bạn cần phải xác định được vị trí "tiếp theo" trong trò chơi và bạn cần biết vị trí hiện tại có phải là vị trí chiến thắng hay không.

 import Control.Monad.Reader

 data GameState = NotOver | FirstPlayerWin | SecondPlayerWin | Tie

 data Game position
   = Game {
           getNext :: position -> [position],
           getState :: position -> GameState
          }

 getNext' :: position -> Reader (Game position) [position]
 getNext' position
   = do game <- ask
        return $ getNext game position

 getState' :: position -> Reader (Game position) GameState
 getState' position
   = do game <- ask
        return $ getState game position


 negamax :: Double -> position -> Reader (Game position) Double
 negamax color position
     = do state <- getState' position 
          case state of
             FirstPlayerWin -> return color
             SecondPlayerWin -> return $ negate color
             Tie -> return 0
             NotOver -> do possible <- getNext' position
                           values <- mapM ((liftM negate) . negamax (negate color)) possible
                           return $ maximum values

Sau đó, điều này sẽ hoạt động với bất kỳ trò chơi hai người chơi hữu hạn, xác định nào.

Mô hình này hữu ích ngay cả đối với những thứ không thực sự là tiêm phụ thuộc. Giả sử bạn làm việc trong lĩnh vực tài chính, bạn có thể thiết kế một số logic phức tạp để định giá một tài sản (ví dụ như phái sinh), điều này hoàn toàn ổn và tốt và bạn có thể làm mà không cần bất kỳ mô hình hôi thối nào. Nhưng sau đó, bạn sửa đổi chương trình của mình để giao dịch với nhiều loại tiền tệ. Bạn cần có thể chuyển đổi nhanh chóng giữa các loại tiền tệ. Nỗ lực đầu tiên của bạn là xác định một hàm cấp cao nhất

type CurrencyDict = Map CurrencyName Dollars
currencyDict :: CurrencyDict

để nhận giá giao ngay. Sau đó, bạn có thể gọi từ điển này trong mã của mình .... nhưng hãy đợi! Điều đó sẽ không hoạt động! Từ điển tiền tệ là bất biến và vì vậy phải giống nhau không chỉ cho vòng đời của chương trình của bạn, mà từ thời điểm nó được biên dịch ! Vậy bạn làm gì? Chà, một tùy chọn sẽ là sử dụng đơn nguyên Reader:

 computePrice :: Reader CurrencyDict Dollars
 computePrice
    = do currencyDict <- ask
      --insert computation here

Có lẽ trường hợp sử dụng cổ điển nhất là trong việc triển khai trình thông dịch. Tuy nhiên, trước khi xem xét điều đó, chúng ta cần giới thiệu một chức năng khác

 local :: (env -> env) -> Reader env a -> Reader env a

Được rồi, Haskell và các ngôn ngữ hàm khác dựa trên phép tính lambda . Phép tính Lambda có cú pháp giống như

 data Term = Apply Term Term | Lambda String Term | Var Term deriving (Show)

và chúng tôi muốn viết một người đánh giá cho ngôn ngữ này. Để làm như vậy, chúng ta sẽ cần theo dõi một môi trường, đó là một danh sách các ràng buộc được liên kết với các điều khoản (thực ra nó sẽ là các bao đóng vì chúng ta muốn thực hiện phạm vi tĩnh).

 newtype Env = Env ([(String, Closure)])
 type Closure = (Term, Env)

Khi chúng ta hoàn tất, chúng ta sẽ nhận ra một giá trị (hoặc một lỗi):

 data Value = Lam String Closure | Failure String

Vì vậy, hãy viết trình thông dịch:

interp' :: Term -> Reader Env Value
--when we have a lambda term, we can just return it
interp' (Lambda nv t)
   = do env <- ask
        return $ Lam nv (t, env)
--when we run into a value, we look it up in the environment
interp' (Var v)
   = do (Env env) <- ask
        case lookup (show v) env of
          -- if it is not in the environment we have a problem
          Nothing -> return . Failure $ "unbound variable: " ++ (show v)
          -- if it is in the environment, then we should interpret it
          Just (term, env) -> local (const env) $ interp' term
--the complicated case is an application
interp' (Apply t1 t2)
   = do v1 <- interp' t1
        case v1 of
           Failure s -> return (Failure s)
           Lam nv clos -> local (\(Env ls) -> Env ((nv, clos) : ls)) $ interp' t2
--I guess not that complicated!

Cuối cùng, chúng ta có thể sử dụng nó bằng cách vượt qua một môi trường tầm thường:

interp :: Term -> Value
interp term = runReader (interp' term) (Env [])

Và đó là nó. Một trình thông dịch đầy đủ chức năng cho phép tính lambda.


Một cách khác để nghĩ về điều này là hỏi: Nó được thực hiện như thế nào? Câu trả lời là đơn nguyên reader thực sự là một trong những đơn nguyên đơn giản và thanh lịch nhất trong số các đơn nguyên.

newtype Reader env a = Reader {runReader :: env -> a}

Reader chỉ là một cái tên ưa thích cho các chức năng! Chúng tôi đã định nghĩa rồi, runReadervậy còn các phần khác của API thì sao? Vâng, mỗi Monadcũng là một Functor:

instance Functor (Reader env) where
   fmap f (Reader g) = Reader $ f . g

Bây giờ, để có được một đơn nguyên:

instance Monad (Reader env) where
   return x = Reader (\_ -> x)
   (Reader f) >>= g = Reader $ \x -> runReader (g (f x)) x

mà không phải là quá đáng sợ. askthực sự đơn giản:

ask = Reader $ \x -> x

trong khi localkhông quá tệ:

local f (Reader g) = Reader $ \x -> runReader g (f x)

Được rồi, vì vậy đơn nguyên của trình đọc chỉ là một chức năng. Tại sao lại có Reader? Câu hỏi hay. Thực ra, bạn không cần nó!

instance Functor ((->) env) where
  fmap = (.)

instance Monad ((->) env) where
  return = const
  f >>= g = \x -> g (f x) x

Những điều này thậm chí còn đơn giản hơn. Hơn nữa, askchỉ là idlocalchỉ là thành phần chức năng với thứ tự của các chức năng được chuyển đổi!


6
Câu trả lời rất thú vị. Thành thật mà nói, tôi đã đọc lại nó nhiều lần, khi tôi muốn xem lại đơn nguyên. Nhân tiện, về thuật toán nagamax, "giá trị <- mapM (phủ định. Negamax (màu phủ định)) có thể" có vẻ không đúng. Tôi biết rằng, mã bạn cung cấp chỉ để hiển thị cách hoạt động của đơn nguyên trình đọc. Nhưng nếu bạn có thời gian, bạn có thể sửa mã của thuật toán negamax? Bởi vì, thật thú vị, khi bạn sử dụng đơn nguyên của trình đọc để giải quyết negamax.
chipbk10

4
Vì vậy, Readercó phải là một chức năng với một số triển khai cụ thể của lớp kiểu đơn nguyên? Nói điều đó sớm hơn sẽ giúp tôi bớt bối rối hơn một chút. Đầu tiên tôi không hiểu. Tôi nghĩ "Ồ, nó cho phép bạn trả về thứ gì đó sẽ cho bạn kết quả mong muốn khi bạn cung cấp giá trị còn thiếu." Tôi nghĩ điều đó hữu ích, nhưng đột nhiên nhận ra rằng một hàm thực hiện chính xác điều này.
ziggystar

1
Sau khi đọc điều này, tôi hiểu hầu hết nó. Các localchức năng không cần một số lời giải thích hơn mặc dù ..
Christophe De Troyer

@Philip Tôi có một câu hỏi về phiên bản Monad. Chúng ta không thể viết hàm ràng buộc dưới dạng (Reader f) >>= g = (g (f x))?
zeronone

@zeronone ở đâu x?
Ashish Negi

56

Tôi nhớ mình đã từng bối rối như bạn, cho đến khi tôi tự mình phát hiện ra rằng các biến thể của đơn nguyên Reader có ở khắp mọi nơi . Làm thế nào tôi phát hiện ra nó? Bởi vì tôi tiếp tục viết mã hóa ra là các biến thể nhỏ trên đó.

Ví dụ, có lúc tôi đang viết một số mã để xử lý các giá trị lịch sử ; giá trị thay đổi theo thời gian. Một mô hình rất đơn giản của điều này là các hàm từ thời điểm đến giá trị tại thời điểm đó:

import Control.Applicative

-- | A History with timeline type t and value type a.
newtype History t a = History { observe :: t -> a }

instance Functor (History t) where
    -- Apply a function to the contents of a historical value
    fmap f hist = History (f . observe hist)

instance Applicative (History t) where
    -- A "pure" History is one that has the same value at all points in time
    pure = History . const

    -- This applies a function that changes over time to a value that also 
    -- changes, by observing both at the same point in time.
    ff <*> fx = History $ \t -> (observe ff t) (observe fx t)

instance Monad (History t) where
    return = pure
    ma >>= f = History $ \t -> observe (f (observe ma t)) t

Các Applicativephương tiện dụ rằng nếu bạn có employees :: History Day [Person]customers :: History Day [Person]bạn có thể làm điều này:

-- | For any given day, the list of employees followed by the customers
employeesAndCustomers :: History Day [Person]
employeesAndCustomers = (++) <$> employees <*> customers

Tức là FunctorApplicativecho phép chúng tôi điều chỉnh các chức năng thông thường, phi lịch sử để làm việc với lịch sử.

Cá thể đơn nguyên được hiểu một cách trực quan nhất bằng cách xem xét chức năng (>=>) :: Monad m => (a -> m b) -> (b -> m c) -> a -> m c. Hàm kiểu a -> History t blà một hàm ánh xạ alịch sử của các bgiá trị; ví dụ, bạn có thể có getSupervisor :: Person -> History Day Supervisor, và getVP :: Supervisor -> History Day VP. Vì vậy, ví dụ Monad dành cho Historyviệc soạn các hàm như thế này; ví dụ, getSupervisor >=> getVP :: Person -> History Day VPlà chức năng nhận, đối với bất kỳ Person, lịch sử của VPhọ mà họ đã có.

Vâng, điều này Historyđơn nguyên thực sự là chính xác giống như Reader. History t athực sự giống như Reader t a(giống như t -> a).

Một ví dụ khác: Gần đây tôi đã tạo mẫu thiết kế OLAP trong Haskell. Một ý tưởng ở đây là "siêu khối", là một ánh xạ từ các giao điểm của một tập hợp các kích thước đến các giá trị. Ở đây chúng tôi tiếp tục:

newtype Hypercube intersection value = Hypercube { get :: intersection -> value }

Một hoạt động phổ biến trên siêu ống là áp dụng các hàm vô hướng nhiều vị trí cho các điểm tương ứng của siêu hình lập phương. Điều này chúng ta có thể nhận được bằng cách xác định một Applicativethể hiện cho Hypercube:

instance Functor (Hypercube intersection) where
    fmap f cube = Hypercube (f . get cube)


instance Applicative (Hypercube intersection) where
    -- A "pure" Hypercube is one that has the same value at all intersections
    pure = Hypercube . const

    -- Apply each function in the @ff@ hypercube to its corresponding point 
    -- in @fx@.
    ff <*> fx = Hypercube $ \x -> (get ff x) (get fx x)

Tôi chỉ sao chép Historymã ở trên và thay đổi tên. Như bạn có thể nói, Hypercubecũng chỉ là Reader.

Nó đi và về. Ví dụ: thông dịch viên ngôn ngữ cũng giảm xuống Reader, khi bạn áp dụng mô hình này:

  • Biểu thức = a Reader
  • Biến tự do = sử dụng ask
  • Môi trường đánh giá = Readermôi trường thực thi.
  • Cấu trúc ràng buộc = local

Một phép tương tự tốt là một Reader r ađại diện cho một acó "lỗ" trong đó, ngăn bạn biết achúng ta đang nói về cái gì. Bạn chỉ có thể nhận được một thực tế akhi bạn cung cấp một an rđể lấp đầy các lỗ. Có rất nhiều thứ như vậy. Trong các ví dụ trên, "lịch sử" là một giá trị không thể được tính cho đến khi bạn chỉ định thời gian, siêu khối là một giá trị không thể tính cho đến khi bạn chỉ định một giao điểm và một biểu thức ngôn ngữ là một giá trị có thể không được tính cho đến khi bạn cung cấp các giá trị của các biến. Nó cũng cung cấp cho bạn một trực giác về lý do tại sao Reader r agiống như r -> a, bởi vì một chức năng như vậy cũng trực quan là một athiếu một r.

Vì vậy Functor, Applicativevà các Monadtrường hợp của Readerlà một khái quát rất hữu ích cho các trường hợp bạn đang mô hình hóa bất kỳ thứ gì thuộc loại "một acái bị thiếu một r", và cho phép bạn xử lý những đối tượng "không hoàn chỉnh" này như thể chúng đã hoàn chỉnh.

Tuy nhiên, một cách khác để nói điều tương tự: a Reader r alà thứ gì đó tiêu thụ rvà sản xuất a, và các Functor, ApplicativeMonadinstance là các mẫu cơ bản để làm việc với Readers. Functor= make a Readersửa đổi đầu ra của cái khác Reader; Applicative= kết nối hai Readers với cùng một đầu vào và kết hợp đầu ra của chúng; Monad= kiểm tra kết quả của a Readervà sử dụng nó để tạo ra kết quả khác Reader. Hàm localand withReader= make a Readersửa đổi đầu vào thành một hàm khác Reader.


5
Câu trả lời chính xác. Bạn cũng có thể sử dụng GeneralizedNewtypeDerivingphần mở rộng để lấy được Functor, Applicative, Monad, vv cho newtypes dựa trên các loại cơ bản của họ.
Rein Henrichs

20

Trong Java hoặc C ++, bạn có thể truy cập bất kỳ biến nào từ bất kỳ đâu mà không gặp vấn đề gì. Các vấn đề xuất hiện khi mã của bạn trở nên đa luồng.

Trong Haskell, bạn chỉ có hai cách để truyền giá trị từ hàm này sang hàm khác:

  • Bạn chuyển giá trị qua một trong các tham số đầu vào của hàm có thể gọi. Hạn chế là: 1) bạn không thể chuyển TẤT CẢ các biến theo cách đó - danh sách các tham số đầu vào chỉ làm bạn suy nghĩ. 2) theo trình tự các lệnh gọi hàm:, fn1 -> fn2 -> fn3hàm fn2có thể không cần tham số mà bạn truyền từ đó fn1sang fn3.
  • Bạn chuyển giá trị trong phạm vi của một số đơn nguyên. Hạn chế là: bạn phải hiểu rõ quan niệm của Monad là gì. Chuyển các giá trị xung quanh chỉ là một trong rất nhiều ứng dụng mà bạn có thể sử dụng Monads. Trên thực tế, sự thụ thai của Monad có sức mạnh phi thường. Đừng buồn, nếu bạn không có được cái nhìn sâu sắc ngay lập tức. Chỉ cần tiếp tục cố gắng và đọc các hướng dẫn khác nhau. Kiến thức bạn nhận được sẽ được đền đáp.

Đơn nguyên Reader chỉ chuyển dữ liệu bạn muốn chia sẻ giữa các chức năng. Các hàm có thể đọc dữ liệu đó, nhưng không thể thay đổi nó. Đó là tất cả những gì làm nên đơn nguyên Reader. Vâng, gần như tất cả. Ngoài ra còn có một số chức năng như local, nhưng lần đầu tiên bạn chỉ có thể gắn bó với asks.


3
Một nhược điểm nữa của việc sử dụng monads để truyền dữ liệu một cách ngầm định là bạn rất dễ thấy mình viết nhiều mã 'kiểu mệnh lệnh' trong dochú thích, điều này sẽ tốt hơn nếu được cấu trúc lại thành một hàm thuần túy.
Benjamin Hodgson

4
@BenjaminHodgson Viết mã 'bắt buộc phải có' với monads trong do -notation không cần thiết có nghĩa là viết mã hiệu quả bên (không tinh khiết). Trên thực tế, mã hiệu quả bên trong Haskell có thể chỉ có bên trong đơn nguyên IO.
Dmitry Bespalov

Nếu hàm kia được gắn với một wheremệnh đề, nó có được chấp nhận là cách thứ 3 để truyền biến không?
Elmex80s
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.