0

I'm looking for some help on how to sum a value within an array of structs.

If I have a struct defined like this:

struct Item {
    let value : Float
    let name  : String
    let planDate : String
}

And then an array of these structs like this:

let dataArray = [Item(value:100, name:"apple", planDate:"2020-10-01"),
                 Item(value:200, name:"lemon", planDate:"2020-10-04"),
                 Item(value:300, name:"apple", planDate:"2020-10-04"),
                 Item(value:400, name:"apple", planDate:"2020-10-01")
]

How can I sum the value while grouping by the name and planDate as well as sorting by name and planDate?

Here's what I'd like to return:

let resultArray = [Item(value:500, name:"apple", planDate:"2020-10-01"),
                   Item(value:300, name:"apple", planDate:"2020-10-04"),
                   Item(value:200, name:"lemon", planDate:"2020-10-04")
]
0

4 Answers 4

2

The easiest way (well, easy is in the eye of the beholder) is to make a dictionary that groups by a composite of your criteria, name and planDate. Now for each entry in the dictionary you've got an array of all the Items that go together! So just sum their values. Now make the dictionary back into an array and sort it.

let dataArray = [Item(value:100, name:"apple", planDate:"2020-10-01"),
                 Item(value:200, name:"lemon", planDate:"2020-10-04"),
                 Item(value:300, name:"apple", planDate:"2020-10-04"),
                 Item(value:400, name:"apple", planDate:"2020-10-01")
]
let dict = Dictionary(grouping: dataArray) { $0.name + $0.planDate }
let dict2 = dict.mapValues { (arr:[Item]) -> Item in
    let sum = arr.reduce(0) {
        $0 + $1.value
    }
    return Item(value:sum, name:arr[0].name, planDate:arr[0].planDate)
}
let dataArray2 = dict2.values.sorted { ($0.name, $0.planDate) < ($1.name, $1.planDate) }
print(dataArray2)
Sign up to request clarification or add additional context in comments.

1 Comment

Thanks for this. I went with this approach as it was a little easier to read and get in my "newbie" Swift developer head.
1

I would take a different approach as well. First make your Item conform to Equatable and Comparable. Then you can reduce your sorted items, check if each item is equal to the last item of the result. If true increase the value otherwise append a new item to the result:

extension Item: Equatable, Comparable {
    static func ==(lhs: Self, rhs: Self) -> Bool {
        (lhs.name, lhs.planDate) == (rhs.name, rhs.planDate)
    }
    static func < (lhs: Self, rhs: Self) -> Bool {
        (lhs.name, lhs.planDate) < (rhs.name, rhs.planDate)
    }
}

let result: [Item] = items.sorted().reduce(into: []) { partial, item in
    if item == partial.last {
        partial[partial.endIndex-1].value += item.value
    } else {
        partial.append(item)
    }
}

Comments

0

I'll offer another variant using Dictionary(_:uniquingKeysWith:):

let dict = Dictionary(dataArray.map { ($0.name + $0.planDate, $0) },
      uniquingKeysWith: {           
         Item(value: $0.value + $1.value, name: $0.name, planDate: $0.planDate)
      })

let result = dict.values.sorted {($0.name, $0.planDate) < ($1.name, $1.planDate)}

Comments

0

For completeness, here's a solution that explicitly makes use of the fact that you want to use name&planDate as both group identifier, and sort key.

You can make use of the Identifiable protocol, and build a struct with name&planDate (structs are almost free in Swift):

extension Item: Identifiable {
    struct ID: Hashable, Comparable {
        let name: String
        let planDate: String
        
        static func < (lhs: Item.ID, rhs: Item.ID) -> Bool {
            (lhs.name, lhs.planDate) < (rhs.name, rhs.planDate)
        }
    }
    
    var id: ID { ID(name: name, planDate: planDate) }
    
    // this will come in handly later
    init(id: ID, value: Float) {
        self.init(value: value, name: id.name, planDate: id.planDate)
    }
}

Then you can destructure the Item struct by its identifier, accumulate the values, and restructure it back:

let valueGroups = dataArray
    .reduce(into: [:]) { groups, item in
        // here we destructure the Item into id and value, and accumulate the value
        groups[item.id, default: 0] += item.value
    }
    .sorted { $0.key < $1.key } // the key of the dictionary is the id, we sort by that

// and were we restructure it back
let result = valueGroups.map(Item.init(id:value:))

We can take it even further and refine the operations we need by extensing Sequence:

extension Sequence {
    
    /// returns an array made of the sorted elements by the given key path
    func sorted<T>(by keyPath: KeyPath<Element, T>) -> [Element] where T: Comparable {
        sorted { $0[keyPath: keyPath] < $1[keyPath: keyPath] }
    }
    
    /// Accumulates the values by the specified key
    func accumulated<K, T>(values: KeyPath<Element, T>,
                           by key: KeyPath<Element, K>) -> [K:T]
    where K: Hashable, T: AdditiveArithmetic {
        reduce(into: [:]) { $0[$1[keyPath: key], default: T.zero] += $1[keyPath: values] }
    }
}

The two new additions, sort by a key path, and accumulate key paths by using another key, are independent enough to deserve function for they own, as they are generic enough to be reusable in other contexts.

The actual business logic becomes simple as

let result = dataArray
    .accumulated(values: \.value, by: \.id)
    .map(Item.init(id:value:))
    .sorted(by: \.id)

Even if this solution is more verbose than the other one, it has the following advantages:

  • clear separation of concerns
  • breaking the code into smaller units, which can be independently unit tested
  • code reusability
  • simple caller code, easy to understand and review

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.