Change Variable only if other Variables have changed and Update is requested

Hello,
I have the following Problem:

struct Item {
    var foo: Int

    init(_ iFoo: Int = 0){
        self.foo = iFoo
    }
}

class TestObject {
    @Published var items = [Item(1), Item(2), Item(3), Item(4)]

    private var avg:Double = 0.0{
        didSet{
            print("didSet: avg: '\(self.avg)'")
        }
    }

    private var cancellableSet: Set<AnyCancellable> = []
    private var isItemChangedPublisher: AnyPublisher<[Item], Never>{
        self.$items
            .eraseToAnyPublisher()
    }

    init(){
        self.isItemChangedPublisher
            .map{ items in
                var sum = 0
                for item in items{
                    sum += item.foo
                }
                return Double(sum)/Double(items.count)}
            .assign(to: \.avg, on: self)
            .store(in: &cancellableSet)
    }

    func changeItem(at index: Int, to value: Int){
        if self.items.count < index{
            self.items.append(Item(value))
        }else{
            self.items[index].foo = value
        }
    }

    func getAvg() -> Double{
        //Request: Items changed --> Change Value of avg here
        //Set Value of avg only if items has changed AND "Request" is called
        //  - don't set the new Value if Items has not changed and "Request" is called
        //  - don't set the new Value if Items has changed, but "Request" is not called
        return self.avg
    }
}

var bar = TestObject()

bar.changeItem(at: 2, to: 20)
bar.changeItem(at: 0, to: 3)

print("1. avg: '\(bar.getAvg())'")

bar.changeItem(at: 2, to: 20)

print("2. avg: '\(bar.getAvg())'")

bar.changeItem(at: 2, to: 30)

print("3. avg: '\(bar.getAvg())'")

bar.changeItem(at: 2, to: 20)

The Value of var avg is set every Time I change the items-Array. I understand that this is the intended behavior.

But is there any way to update the Variable avg only if the items-Array has changed AND something like a "Request" is called. If only the items have been changed the Variable avg should not be update, also if only the "Request" is called, but no items have been changed, the Variable shouldn't be updated.

I don't have any clue how to do this.

Do you have any idea to do this with the combine framework or with another solution?

I ask the same Question on Stackoverflow, I will keep the posts in sync

What is Request?

It´s only a placeholder from me, to show I would like to make a "Request" to get new Data from the Publisher, if items have changed. It´s like a function call or something else, to let the Publisher/Subscriber know that they should push a new value to the variable avg

Okay, so basically:

  1. You have an array of items.
  2. You have a variable that returns the average of Item.foo.
  3. You want the value of that variable to only be updated on certain conditions.

Please correct me if I have misunderstood the problem you're trying to solve.

Right:

  1. I have an Array of Items.
  2. I have a variable that holds the average of Item.foo
  3. I only want to update the variable if something in the Array has changed (for example the value of Item.foo of one entry has changed) and I "ask" or "request" for an update (basically call a function or something to trigger the update) but I don't want to go through the whole Array again

As I type this I have an idea:
I could do it like this:

struct Item {
    var foo: Int
    
    init(_ iFoo: Int = 0){
        self.foo = iFoo
    }
}

class TestObject {
    var items = [Item(1), Item(2), Item(3), Item(4)] {
        didSet{
            self.itemsChanged = true
            print("didSet: items --> itemsChanged: '\(self.itemsChanged)'")
        }
    }
    private var itemsChanged: Bool = false
    
    private var avg:Double = 0.0{
        didSet{
            print("didSet: avg: '\(self.avg)'")
        }
    }
    
    func changeItem(at index: Int, to value: Int){
        if self.items.count < index{
            self.items.append(Item(value))
        }else{
            self.items[index].foo = value
        }
    }
    
    func getAvg() -> Double{
        if self.itemsChanged{
            var sum = 0
            for item in self.items{
                sum += item.foo
            }
            self.avg = Double(sum)/Double(items.count)
            self.itemsChanged = false
        }
        return self.avg
    }
}

var bar = TestObject()

bar.changeItem(at: 2, to: 20)
bar.changeItem(at: 0, to: 3)

print("1. avg: '\(bar.getAvg())'")

bar.changeItem(at: 2, to: 20)

print("2. avg: '\(bar.getAvg())'")

//bar.changeItem(at: 2, to: 30)

print("3. avg: '\(bar.getAvg())'")

bar.changeItem(at: 2, to: 20)

But there is probably a more elegant way to do it. I tried combine because I was hoping to get something like a pipe, which I could ask to update the variable avg if I need the update and if the Items-Array has changed. I am open to better suggestions than mine.

If you want the average to only be updated when (1) an item is added to the array or (2) an item's value is updated, then what you're doing looks fine to me. Here's how I would do it:

struct Item {
  var foo: Int
  init(_ foo: Int = 0) {
    self.foo = foo
  }
}

final class ItemContainer {
  private var items: [Item] = []
  private(set) var average: Double = 0.0

  init(items: [Item]) {
	self.items = items
	updateAverage()
  }
  
  // The adding part should be done by a separate method
  // IMO, otherwise this method name is confusing
  func updateItemValue(to value: Int, atIndex index: Int) {
    if items.indices ~= index {
      items[index].foo = value
    } else {
      items.append(.init(value))
    }
    updateAverage()
  }

  private func updateAverage() {
    let sum = items.map { $0.foo }.reduce(0, +)
    average = Double(sum) / Double(items.count)
  }
}

class TestObject {
  var items = ItemContainer(items: [Item(1), Item(2), Item(3), Item(4)])

  func test() {
    print(items.average) // 2.5
    items.updateItemValue(to: 4, atIndex: 0)
    print(items.average) // 3.25
  }
}

let object = TestObject()
object.test()

I think you could also change the updateAverage method to do incremental averaging instead, if you want to avoid going through the entire array each time an item is added or updated.

1 Like

I came up with to other ideas, because I still wonder if the whole thing can be solved with combine:

First one:

import Combine

struct Item: Equatable {
    var foo: Int
    
    init(_ iFoo: Int = 0){
        self.foo = iFoo
    }
}

class TestObject {
    @Published var items = [Item(1), Item(2), Item(3), Item(4)]
    
    private var newAverage: Double? {
        didSet{
            print("didSet: items changed --> newAverage: '\(String(describing: self.newAverage))'")
        }
    }
    
    private var average:Double = 0.0{
        didSet{
            print("didSet: average: '\(self.average)'")
        }
    }
    
    private var cancellable: AnyCancellable?
    private var isItemChangedPublisher: AnyPublisher<[Item], Never>{
        self.$items
            .eraseToAnyPublisher()
    }
    
    init(){
        cancellable = self.isItemChangedPublisher
            .removeDuplicates()
            .map{Double($0.map{$0.foo}.reduce(0, +))/Double($0.count)}
            .sink{self.newAverage = $0}
    }
    
    func changeItem(at index: Int, to value: Int){
        if self.items.count < index{
            self.items.append(Item(value))
        }else{
            self.items[index].foo = value
        }
    }
    
    func getAverage() -> Double{
        if self.newAverage != nil{
            self.average = self.newAverage!
            self.newAverage = nil
        }
        return self.average
    }
}

var bar = TestObject()

bar.changeItem(at: 2, to: 20)
bar.changeItem(at: 0, to: 20)
print("1. avg: '\(bar.getAverage())'")
bar.changeItem(at: 1, to: 20)
print("2. avg: '\(bar.getAverage())'")
bar.changeItem(at: 1, to: 20)
print("3. avg: '\(bar.getAverage())'")
bar.changeItem(at: 3, to: 20)

/*
 prints:
 didSet: items changed --> newAverage: 'Optional(2.5)'
 didSet: items changed --> newAverage: 'Optional(6.75)'
 didSet: items changed --> newAverage: 'Optional(11.5)'
 didSet: average: '11.5'
 didSet: items changed --> newAverage: 'nil'
 1. avg: '11.5'
 didSet: items changed --> newAverage: 'Optional(16.0)'
 didSet: average: '16.0'
 didSet: items changed --> newAverage: 'nil'
 2. avg: '16.0'
 3. avg: '16.0'
 didSet: items changed --> newAverage: 'Optional(20.0)'
 */

But, I'm still looking for a solution with combine only (without the dirty solution with the newAverage variable).

I also tried a solution with a custom DispatchQueue (it is just an attempt, not necessarily a good solution):

import Combine
import SwiftUI

struct Item: Equatable {
    var foo: Int

    init(_ iFoo: Int = 0){
        self.foo = iFoo
    }
}

struct MyQueue {
//    let queue = DispatchQueue(label: "myQueue", attributes: .concurrent, target: .global())
    let queue = DispatchQueue(label: "myQueue")
    
    init(){
        self.queue.suspend()
    }
    
    func releaseData(){
        self.queue.resume()
        self.queue.suspend()
    }
}

class TestObject {
    @Published var items = [Item(1), Item(2), Item(3), Item(4)]

    private var average:Double = 0.0{
        didSet{
            print("didSet: average: '\(self.average)'")
        }
    }

    private var cancellable: AnyCancellable?
    let myQueue = MyQueue()
    private var isItemChangedPublisher: AnyPublisher<[Item], Never>{
        self.$items
            .eraseToAnyPublisher()
    }

    init(){
        cancellable = self.isItemChangedPublisher
            .removeDuplicates()
            .map{ items in
                Double(items.map{ $0.foo }.reduce(0, +))/Double(items.count)}
            .buffer(size: 1, prefetch: .keepFull, whenFull: .dropOldest) //The Buffer changes nothing
            .receive(on: self.myQueue.queue)
            .assign(to: \.average, on: self)
    }

    func changeItem(at index: Int, to value: Int){
        if self.items.count < index{
            self.items.append(Item(value))
        }else{
            self.items[index].foo = value
        }
    }

    func getAverage() -> Double{
        self.myQueue.releaseData()
        return self.average
    }
}
var bar = TestObject()

bar.changeItem(at: 2, to: 20)
bar.changeItem(at: 0, to: 20)
print("1. avg: '\(bar.getAverage())'")
bar.changeItem(at: 1, to: 20)
print("2. avg: '\(bar.getAverage())'")
bar.changeItem(at: 1, to: 20)
print("3. avg: '\(bar.getAverage())'")
bar.changeItem(at: 3, to: 20)

/*
 Prints:
 
 didSet: average: '2.5'
 1. avg: '2.5'
 didSet: average: '6.75'
 didSet: average: '11.5'
 2. avg: '11.5'
 didSet: average: '16.0'
 3. avg: '16.0'

 
 But im looking for:
 
 didSet: average: '11.5' (because 2.5 and 6.5 are dropped)
 1. avg: '11.5'
 didSet: average: '16.0'
 2. avg: '16.0'
 3. avg: '16.0'
 */

but that doesn't work either...

add removeDuplicates was suggested at Stackoverflow: Change Variable only if other Variables have changed and Update is requested

Is there a reason why you want to solve this problem with Combine instead?

To be honest, mostly for my peace of mind

user3441734 provided an answer with combine on stackoverflow