Update item in tree structure by reference and return updated tree structure

后端 未结 2 683
感动是毒
感动是毒 2021-01-23 17:30

I am currently learning functional programming using HyperappJS (V2) and RamdaJS. My first project is a simple blog app where users can comment on posts or other comments. The c

2条回答
  •  一向
    一向 (楼主)
    2021-01-23 18:19

    Here's an approach where we 1) locate the target object in your state tree, and then 2) transform the located object. Let's assume that your tree has some way to id the individual objects -

    const state =
      { posts:
          [ { id: 1              // <-- id
            , topic: "Topic A"
            , comments: []
            }
          , { id: 2              // <-- id
            , topic: "Topic B"
            , comments: []
            }
          , { id: 3              // <-- id
            , topic: "Topic C"
            , comments: []
            }
          ]
      , otherstuff: [ 1, 2, 3 ]
      }
    

    search

    You could start by writing a generic search which yields the possible path(s) to a queried object -

    const search = function* (o = {}, f = identity, path = [])
    { if (!isObject(o))
        return
    
      if (f (o))
        yield path
    
      for (const [ k, v ] of Object.entries(o))
        yield* search (v, f, [ ...path, k ])
    }
    

    Let's locate all objects where id is greater than 1 -

    for (const path of search (state, ({ id = 0 }) => id > 1))
      console .log (path)
    
    // [ "posts", "1" ]
    // [ "posts", "2" ]
    

    These "paths" point to objects in your state tree where the predicate, ({ id = 0 }) => id > 1), is true. Ie,

    // [ "posts", "1" ]
    state.posts[1] // { id: 2, topic: "Topic B", comments: [] }
    
    // [ "posts", "2" ]
    state.posts[2] // { id: 3, topic: "Topic C", comments: [] }
    

    We will use search to write higher-order functions like searchById, which encodes our intentions more clearly -

    const searchById = (o = {}, q = 0) =>
      search (o, ({ id = 0 }) => id === q)
    
    for (const path of searchById(state, 2))
      console .log (path)
    
    // [ "posts", "1" ]
    

    transform

    Next we can write transformAt which takes an input state object, o, a path, and a transformation function, t -

    const None =
      Symbol ()
    
    const transformAt =
      ( o = {}
      , [ q = None, ...path ] = []
      , t = identity
      ) =>
        q === None                                  // 1
          ? t (o)
      : isObject (o)                                // 2
          ? Object.assign 
              ( isArray (o) ? [] : {}
              , o
              , { [q]: transformAt (o[q], path, t) }
              )
      : raise (Error ("transformAt: invalid path")) // 3
    

    These bullet points correspond to the numbered comments above -

    1. When the query, q, is None, the path has been exhausted and it's time to run the transformation, t, on the input object, o.
    2. Otherwise, by induction, q is not empty. If the input, o, is an object, using Object.assign create a new object where its new q property is a transform of its old q property, o[q].
    3. Otherwise, by induction, q is not empty and o is not an object. We cannot expect to lookup q on a non-object, therefore raise an error to signal to that transformAt was given an invalid path.

    Now we can easily write appendComment which takes an input, state, a comment's id, parentId, and a new comment, c -

    const append = x => a =>
      [ ...a, x ]
    
    const appendComment = (state = {}, parentId = 0, c = {}) =>
    { for (const path of searchById(state, parentId))
        return transformAt   // <-- only transform first; return
          ( state
          , [ ...path, "comments" ]
          , append (c)
          )
      return state // <-- if no search result, return unmodified state
    }
    

    Recall search generates all possible paths to where the predicate query returns true. You have to make a choice how you will handle the scenario where a query returns more than one result. Consider data like -

    const otherState =
      { posts: [ { type: "post", id: 1, ... }, ... ]
      , images: [ { type: "image", id: 1, ... }, ... ]
      }
    

    Using searchById(otherState, 1) would get two objects where id = 1. In appendComment we choose only to modify the first match. It's possible to modify all the search results, if we wanted -

    // but don't actually do this
    const appendComment = (state = {}, parentId = 0, c = {}) =>
      Array
        .from (searchById (state, parentId)) // <-- all results
        .reduce
            ( (r, path) =>
                transformAt  // <-- transform each
                  ( r
                  , [ ...path, "comments" ]
                  , append (c)
                  )
            , state // <-- init state
            )
    

    But in this scenario, we probably don't want duplicate comments in our app. Any querying function like search may return zero, one, or more results and you have to decide how your program responds in each scenario.


    put it together

    Here are the remaining dependencies -

    const isArray =
      Array.isArray
    
    const isObject = x =>
      Object (x) === x
    
    const raise = e =>
      { throw e }
    
    const identity = x =>
      x
    

    Let's append our first new comment to id = 2, "Topic B" -

    const state1 =
      appendComment
        ( state
        , 2
        , { id: 4, text: "nice article!", comments: [] }  
        )
    

    Our first state revision, state1, will be -

    { posts:
        [ { id: 1
          , topic: "Topic A"
          , comments: []
          }
        , { id: 2
          , topic: "Topic B"
          , comments:
              [ { id: 4                     //
                , text: "nice article!"     // <-- newly-added
                , comments: []              //     comment
                }                           //
              ]
          }
        , { id: 3
          , topic: "Topic C"
          , comments: []
          }
        ]
    , otherstuff: [ 1, 2, 3 ]
    }
    

    And we'll append another comment, nested on that one -

    const state2 =
      appendComment
        ( state
        , 4  // <-- id of our last comment
        , { id: 5, text: "i agree!", comments: [] }  
        )
    

    This second revision, state2, will be -

    { posts:
        [ { id: 1, ...}
        , { id: 2
          , topic: "Topic B"
          , comments:
              [ { id: 4
                , text: "nice article!"
                , comments:
                    [ { id: 5             //     nested
                      , text: "i agree!"  // <-- comment
                      , comments: []      //     added
                      }                   //
                    ]
                }
              ]
          }
        , { id: 3, ... }
        ]
    , ...
    }
    

    code demonstration

    In this demo we will,

    • create state1 by modifying state to add the first comment
    • create state2 by modifying state1 to add the second (nested) comment
    • print state2 to show the expected state
    • print state to show that the original state is not modified

    Expand the snippet below to verify the results in your own browser -

    const None = 
      Symbol ()
    
    const isArray =
      Array.isArray
    
    const isObject = x =>
      Object (x) === x
    
    const raise = e =>
      { throw e }
    
    const identity = x =>
      x
    
    const append = x => a =>
      [ ...a, x ]
    
    const search = function* (o = {}, f = identity, path = [])
    { if (!isObject(o))
        return
      
      if (f (o))
        yield path
      
      for (const [ k, v ] of Object.entries(o))
        yield* search (v, f, [ ...path, k ])
    }
    
    const searchById = (o = {}, q = 0) =>
      search (o, ({ id = 0 }) => id === q)
    
    const transformAt =
      ( o = {}
      , [ q = None, ...path ] = []
      , t = identity
      ) =>
        q === None
          ? t (o)
      : isObject (o)
          ? Object.assign
              ( isArray (o) ? [] : {}
              , o
              , { [q]: transformAt (o[q], path, t) }
              )
      : raise (Error ("transformAt: invalid path"))
    
    const appendComment = (state = {}, parentId = 0, c = {}) =>
    { for (const path of searchById(state, parentId))
        return transformAt
          ( state
          , [ ...path, "comments" ]
          , append (c)
          )
      return state
    }
    
    const state =
      { posts:
          [ { id: 1
            , topic: "Topic A"
            , comments: []
            }
          , { id: 2
            , topic: "Topic B"
            , comments: []
            }
          , { id: 3
            , topic: "Topic C"
            , comments: []
            }
          ]
      , otherstuff: [ 1, 2, 3 ]
      }
    
    const state1 =
      appendComment
        ( state
        , 2
        , { id: 4, text: "nice article!", comments: [] }  
        )
    
    const state2 =
      appendComment
        ( state1
        , 4
        , { id: 5, text: "i agree!", comments: [] }  
        )
    
    console.log("state2", JSON.stringify(state2, null, 2))
    console.log("original", JSON.stringify(state, null, 2))


    alternate alternative

    The techniques described above are parallel to the other (excellent) answer using lenses provided by Scott. The notable difference here is we start with an unknown path to the target object, find the path, then transform the state at the discovered path.

    The techniques in these two answers could even be combined. search yields paths that could be used to create R.lensPath and then we could update the state using R.over.

    And a higher-level technique is lurking right around the corner. This one comes from the understanding that writing functions like transformAt is reasonably complex and it's difficult to get them right. At the heart of the problem, our state object is a plain JS object, { ... }, which offers no such feature as immutable updates. Nested within those object we use arrays, [ ... ], that have the same issue.

    Data structures like Object and Array were designed with countless considerations that may not match your own. It is for this reason why you have the ability to design your own data structures that behave the way you want. This is an often overlooked area of programming, but before we jump in and try to write our own, let's see how the Wise Ones before us did it.

    One example, ImmutableJS, solves this exact problem. The library gives you a collection of data structures as well as functions that operate on those data structures, all of which guarantee immutable behaviour. Using the library is convenient -

    const append = x => a => // <-- unused
      [ ...a, x ]
    
    const { fromJS } =
      require ("immutable")
    
    const appendComment = (state = {}, parentId = 0, c = {}) =>
    { for (const path of searchById(state, parentId))
        return transformAt
          ( fromJS (state) // <-- 1. from JS to immutable
          , [ ...path, "comments" ]
          , list => list .push (c) // <-- 2. immutable push
          )
          .toJS () // <-- 3. from immutable to JS
      return state
    }

    Now we write transformAt with the expectation that it will be given an immutable structure -

    const isArray = // <-- unused
      Array.isArray
    
    const isObject = (x) => // <-- unused
      Object (x) === x
    
    const { Map, isCollection, get, set } =
      require ("immutable")
    
    const transformAt =
      ( o = Map ()             // <-- empty immutable object
      , [ q = None, ...path ] = []
      , t = identity
      ) =>
        q === None
          ? t (o)
      : isCollection (o)       // <-- immutable object?
          ? set                // <-- immutable set
              ( o
              , q
              , transformAt
                  ( get (o, q) // <-- immutable get
                  , path
                  , t
                  )
              )
      : raise (Error ("transformAt: invalid path"))

    Hopefully we can begin to see transformAt as a generic function. It is not coincidence that ImmutableJS includes functions to do exactly this, getIn and setIn -

    const None = // <-- unused
      Symbol ()
    
    const raise = e => // <-- unused
      { throw e }
    
    const { Map, setIn, getIn } =
      require ("immutable")
    
    const transformAt =
      ( o = Map () // <-- empty Map
      , path = []
      , t = identity
      ) =>
        setIn // <-- set by path
          ( o
          , path
          , t (getIn (o, path)) // <-- get by path
          )

    To my surprise, even transformAt is implemented exactly as updateIn -

    const identity = x => // <-- unused
      x
    
    const transformAt =  //
      ( o = Map ()       // <-- unused
      , path = []        //   
      , t = identity     // 
      ) => ...           //
    
    const { fromJS, updateIn } =
      require ("immutable")
    
    const appendComment = (state = {}, parentId = 0, c = {}) =>
    { for (const path of searchById(state, parentId))
        return updateIn // <-- immutable update by path
          ( fromJS (state)
          , [ ...path, "comments" ]
          , list => list .push (c)
          )
          .toJS ()
      return state
    }

    This the lesson of higher-level data structures. By using structures designed for immutable operations, we reduce the overall complexity of our entire program. As a result, the program can now be written in less than 30 lines of straightforward code -

    //
    // complete implementation using ImmutableJS
    //
    const { fromJS, updateIn } =
      require ("immutable")
    
    const search = function* (o = {}, f = identity, path = [])
    { if (Object (o) !== o)
        return
    
      if (f (o))
        yield path
    
      for (const [ k, v ] of Object.entries(o))
        yield* search (v, f, [ ...path, k ])
    }
    
    const searchById = (o = {}, q = 0) =>
      search (o, ({ id = 0 }) => id === q)
    
    const appendComment = (state = {}, parentId = 0, c = {}) =>
    { for (const path of searchById(state, parentId))
        return updateIn
          ( fromJS (state)
          , [ ...path, "comments" ]
          , list => list .push (c)
          )
          .toJS ()
      return state
    }
    

    ImmutableJS is just one possible implementation of these structures. Many others exist, each with their unique APIs and trade-offs. You can pick from a pre-made library or you can custom tailor your own data structures to meet your exact needs. Either way, hopefully you can see the benefits provided by well-designed data structures and perhaps gain insight on why popular structures of today were invented in the first place.

    Expand the snippet below to run the ImmutableJS version of the program in your browser -

    const { fromJS, updateIn } =
      Immutable
    
    const search = function* (o = {}, f = identity, path = [])
    { if (Object (o) !== o)
        return
      
      if (f (o))
        yield path
      
      for (const [ k, v ] of Object.entries(o))
        yield* search (v, f, [ ...path, k ])
    }
    
    const searchById = (o = {}, q = 0) =>
      search (o, ({ id = 0 }) => id === q)
    
    const appendComment = (state = {}, parentId = 0, c = {}) =>
    { for (const path of searchById(state, parentId))
        return updateIn
          ( fromJS (state)
          , [ ...path, 'comments' ]
          , list => list .push (c)
          )
          .toJS ()
      return state
    }
    
    const state =
      { posts:
          [ { id: 1
            , topic: 'Topic A'
            , comments: []
            }
          , { id: 2
            , topic: 'Topic B'
            , comments: []
            }
          , { id: 3
            , topic: 'Topic C'
            , comments: []
            }
          ]
      , otherstuff: [ 1, 2, 3 ]
      }
    
    const state1 =
      appendComment
        ( state
        , 2
        , { id: 4, text: "nice article!", comments: [] }  
        )
    
    const state2 =
      appendComment
        ( state1
        , 4
        , { id: 5, text: "i agree!", comments: [] }  
        )
    
    console.log("state2", JSON.stringify(state2, null, 2))
    console.log("original", JSON.stringify(state, null, 2))

提交回复
热议问题