Ramda:折叠一个对象

Ramda: Fold an object

我正在构建一个 PWA,并使用 Ramda 按逻辑构建。我正在尝试构建一个给定 Google Places Detail response returns 自定义地址对象的函数。

让我通过向您展示我的测试来用代码描述它:

assert({
  given: 'a google places api response from Google Places',
  should: 'extract the address',
  actual: getAddressValues({
    address_components: [
      {
        long_name: '5',
        short_name: '5',
        types: ['floor'],
      },
      {
        long_name: '48',
        short_name: '48',
        types: ['street_number'],
      },
      {
        long_name: 'Pirrama Road',
        short_name: 'Pirrama Rd',
        types: ['route'],
      },
      {
        long_name: 'Pyrmont',
        short_name: 'Pyrmont',
        types: ['locality', 'political'],
      },
      {
        long_name: 'Council of the City of Sydney',
        short_name: 'Sydney',
        types: ['administrative_area_level_2', 'political'],
      },
      {
        long_name: 'New South Wales',
        short_name: 'NSW',
        types: ['administrative_area_level_1', 'political'],
      },
      {
        long_name: 'Australia',
        short_name: 'AU',
        types: ['country', 'political'],
      },
      {
        long_name: '2009',
        short_name: '2009',
        types: ['postal_code'],
      },
    ],
    geometry: {
      location: {
        lat: -33.866651,
        lng: 151.195827,
      },
      viewport: {
        northeast: {
          lat: -33.8653881697085,
          lng: 151.1969739802915,
        },
        southwest: {
          lat: -33.86808613029149,
          lng: 151.1942760197085,
        },
      },
    },
  }),
  expected: {
    latitude: -33.866651,
    longitude: 151.195827,
    city: 'Pyrmont',
    zipCode: '2009',
    streetName: 'Pirrama Road',
    streetNumber: '48',
  },
});

如您所见,我想要的地址对象更多 "flat"(缺少更好的术语)。我正在努力编写这个转换函数。我尝试使用 Ramda 的 evolve 来完成它,但它保留了密钥。我需要使用 evolve 来转换对象,然后 reduce 传播密钥的对象。

// Pseudo
({ address_components }) => ({ ...address_components })

我使用 evolve 成功提取了相关信息,并使用 renameKeys 从 Ramda adjunct 中重命名了键,但之后我不知道如何展平该对象。你是怎样做的?或者是否有更简单的方法来实现所需的转换?

编辑:

我找到了一种方法来实现我的转换,但是它非常冗长。我觉得有一种更简单的方法来提取地址数据。无论如何,这是我目前的解决方案:

export const getAddressValues = pipe(
  evolve({
    address_components: pipe(
      reduce(
        (acc, val) => ({
          ...acc,
          ...{
            [head(prop('types', val))]: prop('long_name', val),
          },
        }),
        {}
      ),
      pipe(
        pickAll([
          'route',
          'locality',
          'street_number',
          'country',
          'postal_code',
        ]),
        renameKeys({
          route: 'streetName',
          locality: 'city',
          street_number: 'streetNumber',
          postal_code: 'zipCode',
        }),
        map(ifElse(isNil, always(null), identity))
      )
    ),
    geometry: ({ location: { lat, lon } }) => ({
      latitude: lat,
      longitude: lon,
    }),
  }),
  ({ address_components, geometry }) => ({ ...address_components, ...geometry })
);

编辑: 根据@codeepic 的回答,这是我最终使用的简单 JavaScript 解决方案(虽然 @user3297291 很优雅,我喜欢它):

const getLongNameByType = (arr, type) => 
  arr.find(o => o.types.includes(type)).long_name;

const getAddressValues = ({ address_components: comp, geometry: { location: { lat, lng } } }) => ({
  latitude: lat,
  longitude: lng,
  city: getLongNameByType(comp, 'locality'),
  zipCode: getLongNameByType(comp, 'postal_code'),
  streetName: getLongNameByType(comp, 'route'),
  streetNumber: getLongNameByType(comp, 'street_number'),
  country: getLongNameByType(comp, 'country'),
});

也许没有太大的改进,但我有一些建议:

  • 您可以使用 indexBy 而不是(难以阅读的)内联 reduce 函数。
  • 通过拆分地址和位置逻辑,并制作一个组合助手将两者结合起来,更容易阅读发生的事情(使用 juxtmergeAll
  • 您可以使用 applySpec 而不是 pickAll + renameKeys

const { pipe, indexBy, prop, head, compose, path, map, applySpec, juxt, mergeAll } = R;

const reformatAddress = pipe(
  prop("address_components"),
  indexBy(
    compose(head, prop("types"))
  ),
  applySpec({
    streetName: prop("route"),
    city: prop("locality"),
    streetNumber: prop("street_number"),
    zipCode: prop("postal_code"),
  }),
  map(prop("long_name"))
);

const reformatLocation = pipe(
  path(["geometry", "location"]),
  applySpec({
    latitude: prop("lat"),
    longitude: prop("lng")
  })
);

// Could also be: converge(mergeRight, [ f1, f2 ])
const formatInput = pipe(
  juxt([ reformatAddress, reformatLocation]),
  mergeAll
);

console.log(formatInput(getInput()));


function getInput() { return {address_components:[{long_name:"5",short_name:"5",types:["floor"]},{long_name:"48",short_name:"48",types:["street_number"]},{long_name:"Pirrama Road",short_name:"Pirrama Rd",types:["route"]},{long_name:"Pyrmont",short_name:"Pyrmont",types:["locality","political"]},{long_name:"Council of the City of Sydney",short_name:"Sydney",types:["administrative_area_level_2","political"]},{long_name:"New South Wales",short_name:"NSW",types:["administrative_area_level_1","political"]},{long_name:"Australia",short_name:"AU",types:["country","political"]},{long_name:"2009",short_name:"2009",types:["postal_code"]}],geometry:{location:{lat:-33.866651,lng:151.195827},viewport:{northeast:{lat:-33.8653881697085,lng:151.1969739802915},southwest:{lat:-33.86808613029149,lng:151.1942760197085}}}}; }
<script src="https://cdnjs.cloudflare.com/ajax/libs/ramda/0.26.1/ramda.min.js"></script>

这是在普通 JS 中实现它的方法:很少的代码行,整个魔术发生在 findObjByType 函数中:

const findObjByType = (obj, type) => 
  obj.address_components.find(o => o.types.includes(type));

const getAddressValues = obj => ({
  latitude: obj.geometry.location.lat,
  longitude: obj.geometry.location.lng,
  city: findObjByType(obj, 'locality').long_name,
  zipCode: findObjByType(obj, 'postal_code').long_name,
  streetName: findObjByType(obj, 'route').long_name,
  streetNumber: findObjByType(obj, 'street_number').long_name
});

Ramda 可能会有所帮助,但如果普通 JavaScript 可以用更少的代码也更容易阅读的话,我们就不要为了使用函数库而编写晦涩难懂的代码。

编辑:阅读@user3297291 的回答后,我不得不承认他的 Ramda 解决方案非常优雅,但我的观点仍然成立。如果可以在保持可读性的同时少写代码,就不要多写代码。

stackblitz

上的解决方案

镜头可能是您最好的选择。 Ramda 有一个泛型 lens function, and specific ones for an object property (lensProp), for an array index (lensIndex), and for a deeper path (lensPath),但它不包括通过 id 在数组中查找匹配值的方法。不过,我们自己制作并不难。

通过将两个函数传递给 lens 来制作镜头:一个 getter 获取对象和 return 相应的值,一个 setter 获取新值和对象以及 return 对象的更新版本。

这里我们写 lensMatch 它在给定的 属性 名称与提供的值匹配的数组中查找或设置值。 lensType 只是将 'type' 传递给 lensMatch 以取回一个函数,该函数将采用类型数组和 return 镜头。

使用任何镜头,我们都有 view, set, and over 函数,分别获取、设置和更新值。

const lensMatch = (propName) => (key) => lens ( 
  find ( propEq (propName, key) ),
  (val, arr, idx = findIndex (propEq (propName, key), arr)) =>
     update(idx > -1 ? idx : length(arr), val, arr)
)
const lensTypes = lensMatch ('types')
const longName = (types) => 
  compose (lensProp ('address_components'), lensTypes (types), lensProp ('long_name'))
// can define `shortName` similarly if needed

const getAddressValues = applySpec ( {
  latitude:     view (lensPath (['geometry', 'location', 'lat']) ),
  longitude:    view (lensPath (['geometry', 'location', 'lng']) ),
  city:         view (longName (['locality', 'political']) ),
  zipCode:      view (longName (['postal_code']) ),
  streetName:   view (longName (['route']) ),
  streetNumber: view (longName (['street_number']) ),
})

const response = {"address_components": [{"long_name": "5", "short_name": "5", "types": ["floor"]}, {"long_name": "48", "short_name": "48", "types": ["street_number"]}, {"long_name": "Pirrama Road", "short_name": "Pirrama Rd", "types": ["route"]}, {"long_name": "Pyrmont", "short_name": "Pyrmont", "types": ["locality", "political"]}, {"long_name": "Council of the City of Sydney", "short_name": "Sydney", "types": ["administrative_area_level_2", "political"]}, {"long_name": "New South Wales", "short_name": "NSW", "types": ["administrative_area_level_1", "political"]}, {"long_name": "Australia", "short_name": "AU", "types": ["country", "political"]}, {"long_name": "2009", "short_name": "2009", "types": ["postal_code"]}], "geometry": {"location": {"lat": -33.866651, "lng": 151.195827}, "viewport": {"northeast": {"lat": -33.8653881697085, "lng": 151.1969739802915}, "southwest": {"lat": -33.86808613029149, "lng": 151.1942760197085}}}}

console .log (
  getAddressValues (response)
)
<script src="//cdnjs.cloudflare.com/ajax/libs/ramda/0.26.1/ramda.js"></script><script>
const {applySpec, compose, find, findIndex, lens, lensProp, lensPath, propEq, update, view} = R  </script>

我们可以使用更简单的 lensMatch 来解决这个问题,因为我们没有使用 setter:

const lensMatch = (propName) => (key) => 
  lens (find (propEq (propName, key) ), () => {} )

但我不推荐它。完整的 lensMatch 是一个有用的效用函数。

我们可能希望通过多种方式更改此解决方案。我们可以将 view 移到 longName 中,并编写另一个小助手将 lensPath 的结果包装在 view 中,以简化调用,使其看起来更像这样。

  longitude:    viewPath (['geometry', 'location', 'lng']),
  city:         longName (['locality', 'political']),

或者我们可以为 applySpec 编写一个包装器,也许 viewSpec 只是将所有 属性 函数包装在 view 中。这些留作 reader.

的练习

(这个介绍几乎是从我的 修改而来的。)


更新

我也尝试过完全独立的方法。我认为它的可读性较差,但它的性能可能更高。对比选项很有意思

const makeKey = JSON.stringify

const matchType = (name) => (
  spec,
  desc = spec.reduce( (a, [t, n]) => ({...a, [makeKey (t)]: n}), {})
) => (xs) => xs.reduce(
  (a, { [name]: fld, types }, _, __, k = makeKey(types)) => ({
    ...a,
    ...(k in desc ? {[desc[k]]: fld} : {})
  }), 
  {}
)
const matchLongNames = matchType('long_name')

const getAddressValues2 = lift (merge) (
  pipe (
    prop ('address_components'), 
    matchLongNames ([
      [['locality', 'political'], 'city'],
      [['postal_code'], 'zipCode'],
      [['route'], 'streetName'],
      [['street_number'], 'streetNumber'],
    ])
  ),
  applySpec ({
    latitude: path(['geometry', 'location', 'lat']),
    longitude: path(['geometry', 'location', 'lng']),
  })
)

const response = {"address_components": [{"long_name": "5", "short_name": "5", "types": ["floor"]}, {"long_name": "48", "short_name": "48", "types": ["street_number"]}, {"long_name": "Pirrama Road", "short_name": "Pirrama Rd", "types": ["route"]}, {"long_name": "Pyrmont", "short_name": "Pyrmont", "types": ["locality", "political"]}, {"long_name": "Council of the City of Sydney", "short_name": "Sydney", "types": ["administrative_area_level_2", "political"]}, {"long_name": "New South Wales", "short_name": "NSW", "types": ["administrative_area_level_1", "political"]}, {"long_name": "Australia", "short_name": "AU", "types": ["country", "political"]}, {"long_name": "2009", "short_name": "2009", "types": ["postal_code"]}], "geometry": {"location": {"lat": -33.866651, "lng": 151.195827}, "viewport": {"northeast": {"lat": -33.8653881697085, "lng": 151.1969739802915}, "southwest": {"lat": -33.86808613029149, "lng": 151.1942760197085}}}}

console .log (
  getAddressValues2 (response)
)
<script src="//cdnjs.cloudflare.com/ajax/libs/ramda/0.26.1/ramda.js"></script><script>
const {applySpec, lift, merge, path, pipe, prop} = R                          </script>

此版本将问题一分为二:一个用于更简单的字段,latitudelongitude,另一个用于其他更难匹配的字段,然后简单地合并应用的结果每一个都对响应。

较简单的字段不需要评论。这只是 applySpec and path 的简单应用。另一个封装为 matchType 接受规范匹配输入类型(以及要提取的字段名称)到输出的 属性 名称。它基于类型构建索引 desc(这里使用 JSON.stringify,尽管显然有其他选择)。然后它会减少一个对象数组,找到任何 types 属性 在索引中的对象,并将其值与适当的字段名称联系起来。

这是一个有趣的变体。我仍然更喜欢我的原始版本,但对于大型阵列,这可能会对性能产生重大影响。

另一个更新

看完user633183的回答后,我一直在想我要如何使用这样的东西。在这里使用 Maybes 有很多话要说。但是我可能希望通过两种不同的方式与结果进行交互。一个让我操作 field-by-field,每个都包裹在自己的 Maybe 中。另一个是一个完整的对象,具有它的所有领域;但出于所展示的原因,它必须包装在自己的 Maybe 中。

这是生成第一个变体并包含将其转换为第二个变体的函数的不同版本。

const maybeObj = pipe (
  toPairs,
  map(([k, v]) => v.isJust ? Just([k, v.value]) : Nothing()),
  sequence(Maybe),
  map(fromPairs)
)

const maybeSpec = (spec = {}) => (obj = {}) =>
  Object .entries (spec) .reduce (
    (a, [k, f] ) => ({...a, [k]: Maybe (is (Function, f) && f(obj))}), 
    {}
  )

const findByTypes = (types = []) => (xs = []) =>
  xs .find (x => equals (x.types, types) ) 

const getByTypes = (name) => (types) => pipe (
  findByTypes (types),
  prop (name)
)

const getAddressComponent = (types) => pipe (
  prop ('address_components'),
  getByTypes ('long_name') (types)
)
const response = {"address_components": [{"long_name": "5", "short_name": "5", "types": ["floor"]}, {"long_name": "48", "short_name": "48", "types": ["street_number"]}, {"long_name": "Pirrama Road", "short_name": "Pirrama Rd", "types": ["route"]}, {"long_name": "Pyrmont", "short_name": "Pyrmont", "types": ["locality", "political"]}, {"long_name": "Council of the City of Sydney", "short_name": "Sydney", "types": ["administrative_area_level_2", "political"]}, {"long_name": "New South Wales", "short_name": "NSW", "types": ["administrative_area_level_1", "political"]}, {"long_name": "Australia", "short_name": "AU", "types": ["country", "political"]}, {"long_name": "2009", "short_name": "2009", "types": ["postal_code"]}], "geometry": {"location": {"lat": -33.866651, "lng": 151.195827}, "viewport": {"northeast": {"lat": -33.8653881697085, "lng": 151.1969739802915}, "southwest": {"lat": -33.86808613029149, "lng": 151.1942760197085}}}}

getAddressComponent (['route']) (response)

const extractAddress = maybeSpec({
  latitude:     path (['geometry', 'location', 'lat']),
  longitude:    path (['geometry', 'location', 'lng']),
  city:         getAddressComponent (['locality', 'political']),
  zipCode:      getAddressComponent  (['postal_code']),
  streetName:   getAddressComponent  (['route']),
  streetNumber: getAddressComponent (['street_number']),  
})

const transformed = extractAddress (response)

// const log = pipe (toString, console.log)
const log1 = (obj) => console.log(map(toString, obj))
const log2 = pipe (toString, console.log)

// First variation
log1 (
  transformed
)

// Second variation
log2 (
  maybeObj (transformed)
)
<script src="https://bundle.run/ramda@0.26.1"></script>
<script src="https://bundle.run/ramda-fantasy@0.8.0"></script>
<script>
const {equals, fromPairs, is, map, path, pipe, prop, toPairs, sequence, toString} = ramda;
const {Maybe} = ramdaFantasy;
const {Just, Nothing} = Maybe;
</script>

函数maybeObj转换结构如下:

{
  city: Just('Pyrmont'),
  latitude: Just(-33.866651)
}

变成这样:

Just({
  city: 'Pyrmont',
  latitude: -33.866651
})

但是一个 Nothing:

{
  city: Just('Pyrmont'),
  latitude: Nothing()
}

回成Nothing:

Nothing()

它对对象的作用很像 R.sequence does for arrays and other foldable types. (Ramda, for long, complicated reasons,不将对象视为可折叠。)

其余部分与@user633183 的回答非常相似,但用的是我自己的成语。可能唯一值得注意的其他部分是 maybeSpec,它的行为很像 R.applySpec,但将每个字段包装在 JustNothing.

(请注意,我使用的是 Maybe from Ramda-Fantasy。该项目已停止,我可能应该弄清楚使用其中一个 up-to-date 项目需要进行哪些更改。把它归咎于懒惰。我想,唯一需要的改变是用它们提供的任何函数 [或你自己的] 替换对 Maybe 的调用,以将 nil 值转换为 Nothing 并将所有其他值转换为Justs.)

函数样式的强度依赖于 保证 函数都将值作为 input 和 return 作为值 输出。如果一个函数的输出没有完全定义,我们函数输出的任何消费者都可能受到潜在的未定义行为的影响。 Null-checks 写起来很累,运行时异常令人头疼;我们可以通过遵守职能纪律来避免这两种情况。

您问题中出现的问题是 non-trivial 一个。要检索的数据嵌套很深,访问地址组件需要进行古怪的搜索和匹配。要开始编写我们的转换,我们必须完全定义我们函数的域(输入)和辅域(输出)。

域很简单:您问题中的输入数据是一个对象,因此我们的转换必须为 所有 个对象生成有效结果。 codomain 更具体一点——因为我们的转换有可能以多种方式失败,我们的函数将 return 一个有效的结果对象, 什么都没有。

作为类型签名,它是这样的–

type Result =
  { latitude: Number
  , longitude: Number
  , city: String
  , zipCode: String
  , streetName: String
  , streetNumber: String
  }

transform : Object -> Maybe Result

简单地说,给定有效的输入数据,我们的 transform 将 return 一个有效的结果,例如 –

Just { latitude: 1, longitude: 2, city: "a", zipCode: "b", streetName: "c", streetNumber: "d" }

当给定无效数据时,我们的 transform 将 return 什么都没有–

Nothing

没有其他 return 值是可能的。这意味着我们的函数保证它 不会 return 像 –

这样的部分或稀疏结果
{ latitude: 1, longitude: 2, city: undefined, zipCode: "b", streetName: "c", streetNumber: undefined }

函数纪律也说我们的函数不应该有副作用,所以我们的转换也必须保证它不会抛出错误,比如–

TypeError: cannot read property "location" of undefined
TypeError: data.reduce is not a function

该线程中的其他答案没有采取此类预防措施,当输入数据格式错误时,它们会抛出错误或产生稀疏结果。我们严格的方法将避免这些陷阱,确保您的 transform 函数的任何使用者都不必处理 null-checks 或必须捕获潜在的运行时错误。

在你的问题的核心,我们已经处理了许多潜在的值。我们将使用 data.maybe 包,它提供:

A structure for values that may not be present, or computations that may fail. Maybe(a) explicitly models the effects that implicit in Nullable types, thus has none of the problems associated with using null or undefined — like NullPointerException or TypeError.

听起来很合适。我们将首先草拟一些代码并在空中挥手。假设我们有一个 getAddress 函数,它接受一个 String 和一个 Object 并且 可能 return 一个 String

// getAddress : String -> Object -> Maybe String

我们开始写作 transform ...

const { Just } =
  require ("data.maybe") 

// transform : Object -> Maybe Result
const transform = (data = {}) =>
  getAddress ("locality", data)
    .chain
      ( city =>
          getAddress ("postal_code", data)
            .chain
              ( zipCode =>
                  getAddress ("route", data)
                    .chain
                      ( streetName =>
                          Just ({ city, zipCode, streetName })
                      )
              )
      )

transform (data)
// Just {city: "Pyrmont", zipCode: "2009", streetName: "Pirrama Road"}

transform ({})
// Nothing

好的,哎呀。我们甚至还没有完成,那些嵌套的 .chain 调用完全是一团糟!如果仔细观察,这里有一个简单的模式。功能纪律说当你看到一个模式时,你应该 abstract;这是一个书呆子词,意思是做一个函数

在我们陷入 .chain 地狱之前,让我们考虑一种更通用的方法。我必须在一个深度嵌套的对象中找到六 (6) 个可能的值,如果我能得到所有这些值,我想构造一个 Result 值 –

// getAddress : String -> Object -> Maybe String

// getLocation : String -> Object -> Maybe Number

const { lift } =
  require ("ramda")

// make : (Number, Number, String, String, String, String) -> Result
const make = (latitude, longitude, city, zipCode, streetName, streetNumber) =>
  ({ latitude, longitude, city, zipCode, streetName, streetNumber })

// transform : Object -> Maybe Result
const transform = (o = {}) =>
  lift (make)
    ( getLocation ("lat", o)
    , getLocation ("lng", o)
    , getAddress ("locality", o)
    , getAddress ("postal_code", o)
    , getAddress ("route", o)
    , getAddress ("street_number", o)
    )

transform (data)
// Just {latitude: -33.866651, longitude: 151.195827, city: "Pyrmont", zipCode: "2009", streetName: "Pirrama Road", …}

transform ({})
// Nothing

理智恢复——上面,我们写了一个简单的函数make,它接受六(6)个参数来构造一个Result。使用 lift,我们可以在 Maybecontext 中应用 make,将 Maybe 值作为参数发送。但是,如果任何值是 Nothing,我们将不会返回任何结果,并且不会应用 make

这里已经完成了大部分艰苦的工作。我们只需要完成 getAddressgetLocation 的实现。我们将从 getLocation 开始,这是两者中较简单的一个 –

// safeProp : String -> Object -> Maybe a

// getLocation : String -> Object -> Maybe Number
const getLocation = (type = "", o = {}) =>
  safeProp ("geometry", o)
    .chain (safeProp ("location"))
    .chain (safeProp (type))

getLocation ("lat", data)
// Just {value: -33.866651}

getLocation ("lng", data)
// Just {value: 151.195827}

getLocation ("foo", data)
// Nothing

我们在开始之前没有 safeProp,但是我们通过在进行过程中创造便利来让事情变得简单。功能纪律说功能应该简单并且只做一件事。这样的函数更容易编写、阅读、测试和维护。它们还有一个额外的优势,即它们是可组合的,并且在您程序的其他领域更容易重用。此外,当函数具有 name 时,它允许我们更直接地编码我们的意图 - getLocation 是一系列 safeProp 查找 - 几乎没有其他解释的功能是可能的。

在这个答案的每一部分中,我都揭示了另一个潜在的依赖关系,这似乎很烦人,但这是故意的。我们将继续专注于大局,只有在必要时才会放大较小的部分。 getAddress 由于组件的无序列表,我们的函数必须筛选才能找到特定的地址组件,因此实施起来要困难得多。如果我们继续构建更多功能,请不要感到惊讶–

// safeProp : String -> Object -> Maybe a

// safeFind : (a -> Boolean) -> [ a ] -> Maybe a
const { includes } =
  require ("ramda")

// getAddress : String -> Object -> Maybe String
const getAddress = (type = "", o = {}) =>
  safeProp ("address_components", o)
    .chain
      ( safeFind
          ( o =>
              safeProp ("types", o)
                .map (includes (type))
                .getOrElse (false)
          )
      )
    .chain (safeProp ("long_name"))

有时使用 pipe 将一堆小函数拼凑在一起可能会带来更多麻烦,但得不偿失。当然,可以实现 point-free 语法,但是无数效用函数的复杂序列几乎无法通过 说明 程序实际是什么应该做的。当你在 3 个月后阅读 pipe 时,你会记得你的意图是什么吗?

相比之下,getLocationgetAddress都简单明了。他们不是 point-free,但他们会与 reader 沟通应该完成的工作。此外,domain 和 codomain 在 total 中定义,这意味着我们的 transform 可以与任何其他程序组合并保证工作。好的,让我们揭示剩余的依赖项–

const Maybe =
  require ("data.maybe")

const { Nothing, fromNullable } =
  Maybe

const { identity, curryN, find } =
  require ("ramda")

// safeProp : String -> Object -> Maybe a
const safeProp =
  curryN
    ( 2
    , (p = "", o = {}) =>
        Object (o) === o
          ? fromNullable (o[p])
          : Nothing ()
    )

// safeFind : (a -> Boolean) -> [ a ] -> Maybe a
const safeFind =
  curryN
    ( 2
    , (test = identity, xs = []) =>
        fromNullable (find (test, xs))
    )

以上 curryN 是必需的,因为这些函数具有默认参数。这是一个 trade-off 支持提供更好的自我文档的功能。如果删除默认参数,则可以使用更传统的 curry

让我们看看我们的函数在工作。如果输入有效,我们会得到预期的结果 –

transform (data) .getOrElse ("invalid input")
// { latitude: -33.866651
// , longitude: 151.195827
// , city: "Pyrmont"
// , zipCode: "2009"
// , streetName: "Pirrama Road"
// , streetNumber: "48"
// }

并且因为我们的 transform return 是一个 Maybe,当提供格式错误的输入时我们可以轻松恢复 –

transform ({ bad: "input" }) .getOrElse ("invalid input")
// "invalid input"

运行 这个节目在 repl.it 到 see the results.

希望这种方法的优点是显而易见的。由于 high-level 像 Maybe 和 safePropsafeFind 这样的抽象,我们不仅得到了更健壮和可靠的 transform,而且编写起来也很容易。

在我们分开之前,让我们想想那些大 pipe 作品。他们有时会崩溃的原因是因为 Ramda 库中的 all 函数不是 total – 其中一些 return a non-value, undefined.例如,head 可能会 return undefined,管道中的下一个函数将接收 undefined 作为输入。一旦 undefined 感染了您的管道,所有的安全保证都将消失。另一方面,通过使用专门设计用于处理可空值的数据结构,我们消除了复杂性,同时提供了保证。

扩展这个概念,我们可以寻找 Decoder 库或提供我们自己的库。这样做的目的是加强我们在通用模块中的意图。 getLocationgetAddress 是我们用来使 transform 成为可能的自定义助手——但更一般地说,它是一种解码器形式,所以它有助于我们这样想。此外,解码器数据结构可以在遇到错误时提供更好的反馈——即,我们可以附上原因或有关特定失败的其他信息,而不是仅向我们发出无法生成值的信号的 Nothingdecoders npm 包值得研究。

请参阅 Scott 的回答,以使用称为 lens 的 high-level 抽象的另一种方式解决此问题。但是请注意,该函数是不纯的——需要采取额外的预防措施来防止该函数因格式错误的输入而引发运行时错误。


Scott 的评论提出了一个有效的场景,您可能想要 一个稀疏的结果。我们 可以 将我们的 Result 类型重新定义为 –

type Result =
  { latitude: Maybe Number
  , longitude: Maybe Number
  , city: String
  , zipCode: String
  , streetName: String
  , streetNumber: String
  }

当然,这意味着我们必须重新定义 transform 才能构建这个新结构。但最重要的是,Result 的消费者知道会发生什么,因为密码域是 well-defined.

另一种选择是保留原始 Result 类型,但在无法找到纬度或经度值时指定默认值 –

const transform = (o = {}) =>
  lift (make)
    ( getLocation ("lat", o)
        .orElse (_ => Just (0))
    , getLocation ("lng", o)
        .orElse (_ => Just (0))
    , getAddress ("locality", o)
    , getAddress ("postal_code", o)
    , getAddress ("route", o)
    , getAddress ("street_number", o)
    )

Result 中的每个 字段都可以是可选的,如果您愿意的话。无论哪种方式,我们都必须明确定义域和密码域,并确保我们的 transform 信守承诺。这是它可以安全并入更大程序的唯一方法。