Каков наилучший способ передачи значений между различными процессами в Haskell - PullRequest
3 голосов
/ 10 июля 2020

Что-то, о чем я недавно думал, работая с более крупной системой Haskell, как элегантно распределять значения между процессами?

Я все больше и больше узнаю об отношениях между вводом и выводом функции, l aws, например, инъективность, сюръективность, неинъективные функции и так далее ... я понял, что большинство функций в реальном приложении обрабатывают данные из бизнес-логики c точки зрения уменьшают значения до все меньших и меньших значений, пока вы не окажетесь в каком-то примитивном типе (обычно Bool), конечно, для бизнес-логики условного стиля c. Или, возможно, какое-то другое значение в поле в вашей БД, которое вы хотите обновить.

Итак, давайте посмотрим на пример того, что я могу иметь в виду. Допустим, у нас есть система со таблицей пользователей, таблицей продуктов и, возможно, таблицей предложений. Пользователь может корзину продукта, и продукт может иметь или не иметь предложения, которое вы могли бы применить к нему. Здесь я проиллюстрирую чистую функцию, которая определяет, что предложение применимо к продукту, и другую, которая вычисляет скидку для этого продукта:

-- lets also assume these types are Persistent data types
data User = User
  { userName :: Text
  , userAge :: Int
  , userHasDiscounts :: Bool
  , userAccountDisabled :: Bool
  }

data Product = Product
  { productName :: Text
  , productCost :: Money
  }

data Offer = Offer
  { offerName :: Text
  , offerDiscount :: Discount
  , offerProduct :: ProductId
  }

productDiscountable :: Entity User -> Entity Product -> Entity Offer -> Bool
productDiscountable (Entity _ user) (Entity productKey _) (Entity _ offer) =
  not (userAccountDisabled user) && userHasDiscounts user && productKey == offerProduct offer

productWithDiscountApplied :: Entity User -> Entity Product -> Entity Offer -> Product
productWithDiscountApplied user (Entity productKey product) offer = product { productCost = discountedCost }
  where
    discountedCost = 
      if not (productDiscountable user (Entity productKey product) offer)
        then productCost product
        else applyDiscount (offerDiscount offer) (productCost product)

applyDiscount :: Discount -> Money -> Money
applyDiscount = undefined

Это может быть не очень удачный пример, и мой вопрос больше о том, как все масштабируется в более широкой системе ... но обратите внимание, что в примере для некоторых функций мы отбрасываем данные, которые мы получаем в качестве входных, в productDiscountable мы отбрасываем Product и просто берем ключ объекта, а ключ сущности для User и Offer только для использования внутренних значений. То же самое и в productWithDiscountApplied, где мы берем некоторые значения, которые нам не нужны. Что, если бы мы хотели рассчитать скидку с предложением, которое еще не было сохранено в нашей базе данных, например?

Похоже, что типы имели бы смысл быть такими маленькими, как они должны быть:

productDiscountable :: User -> Key Product -> Offer -> Bool
productDiscountable user productKey offer =
  not (userAccountDisabled user) && userHasDiscounts user && productKey == offerProduct offer

productWithDiscountApplied :: User -> Entity Product -> Offer -> Product
productWithDiscountApplied user (Entity productKey product) offer = product { productCost = discountedCost }
  where
    discountedCost =
      if not (productDiscountable user productKey offer)
        then productCost product
        else applyDiscount (offerDiscount offer) (productCost product)

Мне это кажется намного чище, и я обычно пишу Haskell именно так. Проблема, с которой я столкнулся, заключается в том, что в реальной системе, где все эти элементы должны соответствовать друг другу, разные части системы имеют разные требования к данным, а некоторые части могут потребовать чтения из множества разных таблиц. В системе, над которой я работаю, у нас есть функции, подобные приведенным выше, только в некоторых случаях с 5/6 различными объектами. В этих случаях я иногда определяю более крупный тип, чтобы он содержал все разные части. Что-то вроде:

data SomeProcessType = SomeProcessType
  { sptUser :: Entity User
  , sptProduct :: Entity Product
  , sptOffer :: Entity Offer
  -- possibly more types contained within this type
  }

Но, как и рассуждения о сужении типов с помощью типа Entity, есть причина, по которой мы также можем сузить этот тип, допустим, мы хотим объединить аргументы для productDiscountable в такой тип:

data ProductDiscountableParams = ProductDiscountableParams
  { productDiscountableParamsUser :: User
  , productDiscountableParamsProduct :: Key Product
  , productDiscountableParamsOffer :: Offer
  }

И вы можете сопоставить больший тип с этим типом с помощью некоторой функции:

productDiscountable $ ProductDiscountableParams 
  { productDiscountableParamsUser = entityVal (sptUser spt)
  , productDiscountableParamsProduct = entityKey (sptProduct spt)
  , productDiscountableParamsOffer = entityVal (sptOffer spt)
  }

Но тогда это приводит меня к Ход мысли, что ProductDiscountableParams должен содержать только те значения, которые ему действительно нужны для вычисления результата, подобно тому, как исходной функции не нужны были ключи из Entity, есть также некоторые поля из типов, которые также не требуются. У нас нет доступа к userAge от пользователя, так почему мы его передаем? Что, если бы ProductDiscountableParams был определен только с теми полями, которые ему нужны:

data ProductDiscountableParams = ProductDiscountableParams
  { productDiscountableParamsUserDisabled :: Bool
  , productDiscountableParamsUserHasDiscounts :: Bool
  , productDiscountableParamsOfferProductKey :: Key Product
  , productDiscountableParamsProductKey :: Key Product
  }

productDiscountable $ ProductDiscountableParams 
  { productDiscountableParamsUserDisabled = userDisabled (entityVal (sptUser spt))
  , productDiscountableParamsUserHasDiscounts = userHasDiscounts (entityVal (sptUser spt))
  , productDiscountableParamsOfferProductKey = offerProduct (entityVal (sptOffer spt))
  , productDiscountableParamsProductKey = entityKey (sptProduct spt)
  }

Это кажется хорошим с точки зрения функции, поскольку она принимает только те поля, которые ей требуются. Вероятно, проще написать тест, поскольку ему нужны только некоторые базовые c значения. Но это не кажется таким безопасным (две пары полей с одинаковыми типами), и на него не особенно приятно смотреть с точки зрения вызывающего сайта.

Я начал использовать этот подход в некоторых частях своего

Проблема, связанная с наличием таких типов, как SomeProcessType, охватывающих несколько бизнес-процессов, говорит, что SomeOtherProcessType также существует, с похожим, но не идентичным контекстом, эти два типа нуждаются в способ добраться до ProductDiscountableParams, чтобы вызвать это, если им обоим нужно вызвать эту функцию по какой-либо причине.

Другой подход, который я видел в Интернете, - это люди, использующие классы типов, немного похожие на интерфейс в ОО язык. Вместо этого вы можете определить ProductDiscountableParams как класс типов:

class ProductDiscountableParams a where
  productDiscountableParamsUserDisabled :: a -> Bool
  productDiscountableParamsUserHasDiscounts :: a -> Bool
  productDiscountableParamsOfferProductKey :: a -> Key Product
  productDiscountableParamsProductKey :: a -> Key Product

Вы можете определить здесь экземпляры почти так же, как и сопоставление, описанное ранее:

instance ProductDiscountableParams SomeProcessType where
  productDiscountableParamsUserDisabled = userDisabled . entityVal . sptUser
  productDiscountableParamsUserHasDiscounts = userHasDiscounts . entityVal . sptUser
  productDiscountableParamsOfferProductKey = offerProduct . entityVal . sptOffer
  productDiscountableParamsProductKey = entityKey . sptProduct

Что делает так, что вызывающий сайт стал намного чище:

productDiscountable (spt :: SomeProcessType)

Каковы наилучшие практики, которых следует придерживаться в отношении вышеуказанного? Мне кажется, что постоянная попытка уменьшить тип до его наименьшего представления - это не всегда путь к go, но я также не очень хорошо отношусь к передаче сущностей базы данных, и что должен быть более приятный способ для моделирования ввода / вывода задачи.

1 Ответ

4 голосов
/ 10 июля 2020

Как вы заметили, в бизнес-логах c часто существует компромисс между предоставлением функции минимального домена для обеспечения корректности и удобством вызова этой функции. на ограниченном домене. Типы записей Haskell немного громоздки для этого варианта использования, потому что вы должны явно взаимодействовать между ними и не можете сказать «Я буду использовать только такие-то поля» специальным способом c.

В этой ситуации я делаю одно: разделяю две проблемы, имея реализацию функцию, которая принимает только точную информацию, которая ей нужна, и отдельную интерфейс функцию, которая принимает тип большего размера и извлекает эти поля для передачи в фактические logi c.

productDiscountable :: SomeProcessType -> Bool
productDiscountable spt = productDiscountable u p o
  where
    -- Extract relevant info from larger types.
    u = entityVal (sptUser spt)
    p = entityKey (sptProduct spt)
    o = entityVal (sptOffer spt)

productDiscountable' :: User -> Key Product -> Offer -> Bool
productDiscountable' u p o = …  -- Use restricted types for business logic.

И вы все равно можете создать отдельный тип, например ProductDiscountableParams, для полей с ограниченным доступом, если хотите.

Я бы не обычно использовал бы классы типов для этой цели, потому что все, что они здесь предоставляют, это перегрузка, а не l aws, но если вы хотите go по этому маршруту, потому что у вас есть несколько различных типов, которые соответствуют этому интерфейсу, это естественное место для использования линз.

{-# LANGUAGE InstanceSigs #-}

import Control.Lens (Lens')  -- Use whichever lens library you prefer
import Data.Functor ((<&>))  -- Flipped <$>, convenient for defining lenses

class HasProductDiscountableParams a where
  productDiscountableParams :: Lens' a (User, Key Product, Offer)
  -- or: Lens' a ProductDiscountableParams

instance HasProductDiscountableParams SomeProcessType where

  productDiscountableParams :: Lens' SomeProcessType (User, Key Product, Offer)
  -- ===
  --   :: (Functor f)
  --   => ((User, Key Product, Offer) -> f (User, Key Product, Offer))
  --   -> SomeProcessType -> f SomeProcessType

  productDiscountableParams f
    spt { sptUser = u, sptProduct = p, sptOffer = o }

    -- Extract relevant fields
    = f (u, p, o)

    -- Reconstitute record
    <&> \ (u', p', o') -> spt { sptUser = u { … = u' … }, … }

Затем вы можете использовать view productDiscountableParams spt для извлечения только тех частей, которые вы пересекаете. ested in или over / set, чтобы обновить их. (Я оставил здесь несколько «дыр», потому что я не знаком с Persistent, поэтому я надеюсь, что все ясно.)

Другой вариант, который может быть адаптирован для вашего варианта использования, - это выше- kinded data pattern (HKD), где одна и та же запись повторно используется с разными внутренними типами «оболочки», совпадающими с Identity, чтобы полностью удалить обертку:

{-# LANGUAGE TypeFamilies #-}

import Data.Functor.Identity (Identity)

type family HKD f a where
  HKD Identity a =   a
  HKD f        a = f a

data SomeProcessTypeF f = SomeProcessTypeF
  { sptUser    :: HKD f User
  , sptProduct :: HKD f Product
  , sptOffer   :: HKD f Offer
  …
  }

Затем вы можете создать ее экземпляр в разных типы для восстановления завернутых и развернутых версий:

type SomeProcessType = SomeProcessTypeF Entity

type ProductDiscountableParams = SomeProcessTypeF Identity

Возможно, вам придется преобразовать ваши типы в несколько другую форму, чтобы они соответствовали этому шаблону, или использовать другое семейство типов для обработки преобразований.

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...