从字典中删除嵌套键

时间:2016-10-26 12:07:26

标签: ios swift dictionary collections nsdictionary

我们说我有一个相当复杂的字典,就像这个:

let dict: [String: Any] = [
    "countries": [
        "japan": [
            "capital": [
                "name": "tokyo",
                "lat": "35.6895",
                "lon": "139.6917"
            ],
            "language": "japanese"
        ]
    ],
    "airports": [
        "germany": ["FRA", "MUC", "HAM", "TXL"]
    ]
]

我可以使用if let ..块访问所有字段,可选择在阅读时投射到我可以使用的内容。

但是,我目前正在编写单元测试,我需要以多种方式选择性地中断字典。

但我不知道如何优雅地从字典中删除密钥。

例如,我想在一次测试中移除密钥"japan",在下一个"lat"中应该为零。

这是我目前删除"lat"的实现:

if var countries = dict["countries"] as? [String: Any],
    var japan = countries["japan"] as? [String: Any],
    var capital = japan["capital"] as? [String: Any]
    {
        capital.removeValue(forKey: "lat")
        japan["capital"] = capital
        countries["japan"] = japan
        dictWithoutLat["countries"] = countries
}

当然必须有更优雅的方式吗?

理想情况下,我会编写一个测试帮助程序,它接受一个KVC字符串并具有如下签名:

func dictWithoutKeyPath(_ path: String) -> [String: Any] 

"lat"案例中,我将其与dictWithoutKeyPath("countries.japan.capital.lat")一起使用。

5 个答案:

答案 0 :(得分:6)

使用下标时,如果下标是get / set且变量是可变的,则整个表达式是可变的。但是,由于类型转换,表达式"失败"可变性。 (它不再是l-value了。

解决此问题的最简单方法是创建一个获取/设置的下标并为您进行转换。

extension Dictionary {
    subscript(jsonDict key: Key) -> [String:Any]? {
        get {
            return self[key] as? [String:Any]
        }
        set {
            self[key] = newValue as? Value
        }
    }
}

现在您可以写下以下内容:

dict[jsonDict: "countries"]?[jsonDict: "japan"]?[jsonDict: "capital"]?["name"] = "berlin"

我们非常喜欢这个问题,所以我们决定制作关于它的(公开)Swift Talk剧集:mutating untyped dictionaries

答案 1 :(得分:1)

您可以通过反复尝试将(子)字典值转换为[Key: Any]字典本身来构建访问您给定密钥路径的递归方法(读/写)。此外,允许通过新的subscript公开访问这些方法。

请注意,您可能必须明确导入Foundation才能访问components(separatedBy:)(桥接)的String方法。

extension Dictionary {       
    subscript(keyPath keyPath: String) -> Any? {
        get {
            guard let keyPath = Dictionary.keyPathKeys(forKeyPath: keyPath) 
                else { return nil }
            return getValue(forKeyPath: keyPath)
        }
        set {
            guard let keyPath = Dictionary.keyPathKeys(forKeyPath: keyPath),
                let newValue = newValue else { return }
            self.setValue(newValue, forKeyPath: keyPath)
        }
    }

    static private func keyPathKeys(forKeyPath: String) -> [Key]? {
        let keys = forKeyPath.components(separatedBy: ".")
            .reversed().flatMap({ $0 as? Key })
        return keys.isEmpty ? nil : keys
    }

    // recursively (attempt to) access queried subdictionaries
    // (keyPath will never be empty here; the explicit unwrapping is safe)
    private func getValue(forKeyPath keyPath: [Key]) -> Any? {
        guard let value = self[keyPath.last!] else { return nil }
        return keyPath.count == 1 ? value : (value as? [Key: Any])
                .flatMap { $0.getValue(forKeyPath: Array(keyPath.dropLast())) }
    }

    // recursively (attempt to) access the queried subdictionaries to
    // finally replace the "inner value", given that the key path is valid
    private mutating func setValue(_ value: Any, forKeyPath keyPath: [Key]) {
        guard self[keyPath.last!] != nil else { return }            
        if keyPath.count == 1 {
            (value as? Value).map { self[keyPath.last!] = $0 }
        }
        else if var subDict = self[keyPath.last!] as? [Key: Value] {
            subDict.setValue(value, forKeyPath: Array(keyPath.dropLast()))
            (subDict as? Value).map { self[keyPath.last!] = $0 }
        }
    }
}

示例设置

// your example dictionary   
var dict: [String: Any] = [
    "countries": [
        "japan": [
            "capital": [
                "name": "tokyo",
                "lat": "35.6895",
                "lon": "139.6917"
            ],
            "language": "japanese"
        ]
    ],
    "airports": [
        "germany": ["FRA", "MUC", "HAM", "TXL"]
    ]
]

使用示例:

// read value for a given key path
let isNil: Any = "nil"
print(dict[keyPath: "countries.japan.capital.name"] ?? isNil) // tokyo
print(dict[keyPath: "airports"] ?? isNil)                     // ["germany": ["FRA", "MUC", "HAM", "TXL"]]
print(dict[keyPath: "this.is.not.a.valid.key.path"] ?? isNil) // nil

// write value for a given key path
dict[keyPath: "countries.japan.language"] = "nihongo"
print(dict[keyPath: "countries.japan.language"] ?? isNil) // nihongo

dict[keyPath: "airports.germany"] = 
    (dict[keyPath: "airports.germany"] as? [Any] ?? []) + ["FOO"]
dict[keyPath: "this.is.not.a.valid.key.path"] = "notAdded"

print(dict)
/*  [
        "countries": [
            "japan": [
                "capital": [
                    "name": "tokyo", 
                    "lon": "139.6917",
                    "lat": "35.6895"
                    ], 
                "language": "nihongo"
            ]
        ], 
        "airports": [
            "germany": ["FRA", "MUC", "HAM", "TXL", "FOO"]
        ]
    ] */

请注意,如果赋值(使用setter)不存在提供的键路径,则不会导致构造等效的嵌套字典,只会导致字典没有变异。

答案 2 :(得分:1)

有趣的问题。问题似乎是Swift的可选链接机制,它通常能够改变嵌套的字典,从Any[String:Any]的必要类型转换。因此,虽然访问嵌套元素变得不可读(因为类型转换):

// E.g. Accessing countries.japan.capital
((dict["countries"] as? [String:Any])?["japan"] as? [String:Any])?["capital"]

...改变嵌套元素甚至不起作用:

// Want to mutate countries.japan.capital.name.
// The typecasts destroy the mutating optional chaining.
((((dict["countries"] as? [String:Any])?["japan"] as? [String:Any])?["capital"] as? [String:Any])?["name"] as? String) = "Edo"
// Error: Cannot assign to immutable expression

可能的解决方案

这个想法是摆脱无类字典并将其转换为强类型结构,其中每个元素具有相同的类型。我承认这是一个严厉的解决方案,但它最终运作良好。

具有关联值的枚举适用于替换无类型字典的自定义类型:

enum KeyValueStore {
    case dict([String: KeyValueStore])
    case array([KeyValueStore])
    case string(String)
    // Add more cases for Double, Int, etc.
}

枚举对每个预期的元素类型都有一个案例。这三个案例涵盖了您的示例,但可以轻松扩展以涵盖更多类型。

接下来,我们定义两个下标,一个用于键控访问字典(带字符串),另一个用于索引访问数组(带整数)。下标检查self.dict还是.array,如果是,则返回给定键/索引处的值。如果类型不匹配,则会返回nil,例如如果您尝试访问.string值的密钥。下标也有制定者。这是使链式变异发挥作用的关键:

extension KeyValueStore {
    subscript(_ key: String) -> KeyValueStore? {
        // If self is a .dict, return the value at key, otherwise return nil.
        get {
            switch self {
            case .dict(let d):
                return d[key]
            default:
                return nil
            }
        }
        // If self is a .dict, mutate the value at key, otherwise ignore.
        set {
            switch self {
            case .dict(var d):
                d[key] = newValue
                self = .dict(d)
            default:
                break
            }
        }
    }

    subscript(_ index: Int) -> KeyValueStore? {
        // If self is an array, return the element at index, otherwise return nil.
        get {
            switch self {
            case .array(let a):
                return a[index]
            default:
                return nil
            }
        }
        // If self is an array, mutate the element at index, otherwise return nil.
        set {
            switch self {
            case .array(var a):
                if let v = newValue {
                    a[index] = v
                } else {
                    a.remove(at: index)
                }
                self = .array(a)
            default:
                break
            }
        }
    }
}

最后,我们添加了一些便利初始化器,用于使用字典,数组或字符串文字初始化我们的类型。这些并不是绝对必要的,但更容易使用这种类型:

extension KeyValueStore: ExpressibleByDictionaryLiteral {
    init(dictionaryLiteral elements: (String, KeyValueStore)...) {
        var dict: [String: KeyValueStore] = [:]
        for (key, value) in elements {
            dict[key] = value
        }
        self = .dict(dict)
    }
}

extension KeyValueStore: ExpressibleByArrayLiteral {
    init(arrayLiteral elements: KeyValueStore...) {
        self = .array(elements)
    }
}

extension KeyValueStore: ExpressibleByStringLiteral {
    init(stringLiteral value: String) {
        self = .string(value)
    }

    init(extendedGraphemeClusterLiteral value: String) {
        self = .string(value)
    }

    init(unicodeScalarLiteral value: String) {
        self = .string(value)
    }
}

以下是这个例子:

var keyValueStore: KeyValueStore = [
    "countries": [
        "japan": [
            "capital": [
                "name": "tokyo",
                "lat": "35.6895",
                "lon": "139.6917"
            ],
            "language": "japanese"
        ]
    ],
    "airports": [
        "germany": ["FRA", "MUC", "HAM", "TXL"]
    ]
]

// Now optional chaining works:
keyValueStore["countries"]?["japan"]?["capital"]?["name"] // .some(.string("tokyo"))
keyValueStore["countries"]?["japan"]?["capital"]?["name"] = "Edo"
keyValueStore["countries"]?["japan"]?["capital"]?["name"] // .some(.string("Edo"))
keyValueStore["airports"]?["germany"]?[1] // .some(.string("MUC"))
keyValueStore["airports"]?["germany"]?[1] = "BER"
keyValueStore["airports"]?["germany"]?[1] // .some(.string("BER"))
// Remove value from array by assigning nil. I'm not sure if this makes sense.
keyValueStore["airports"]?["germany"]?[1] = nil
keyValueStore["airports"]?["germany"] // .some(array([.string("FRA"), .string("HAM"), .string("TXL")]))

答案 3 :(得分:1)

我想跟进my previous answer另一个解决方案。这个扩展了Swift的Dictionary类型,带有一个带有关键路径的新下标。

我首先介绍一个名为KeyPath的新类型来表示关键路径。它并不是绝对必要的,但它使得使用关键路径变得更加容易,因为它允许我们将关键路径拆分为其组件的逻辑。

import Foundation

/// Represents a key path.
/// Can be initialized with a string of the form "this.is.a.keypath"
///
/// We can't use Swift's #keyPath syntax because it checks at compilet time
/// if the key path exists.
struct KeyPath {
    var elements: [String]

    var isEmpty: Bool { return elements.isEmpty }
    var count: Int { return elements.count }
    var path: String {
        return elements.joined(separator: ".")
    }

    func headAndTail() -> (String, KeyPath)? {
        guard !isEmpty else { return nil }
        var tail = elements
        let head = tail.removeFirst()
        return (head, KeyPath(elements: tail))
    }
}

extension KeyPath {
    init(_ string: String) {
        elements = string.components(separatedBy: ".")
    }
}

extension KeyPath: ExpressibleByStringLiteral {
    init(stringLiteral value: String) {
        self.init(value)
    }
    init(unicodeScalarLiteral value: String) {
        self.init(value)
    }
    init(extendedGraphemeClusterLiteral value: String) {
        self.init(value)
    }
}

接下来,我创建一个名为StringProtocol的虚拟协议,我们稍后需要约束我们的Dictionary扩展名。 Swift 3.0尚不支持将泛型参数约束为具体类型(例如extension Dictionary where Key == String)的泛型类型的扩展。计划对Swift 4.0提供支持,但在此之前,我们需要这个小解决方法:

// We need this because Swift 3.0 doesn't support extension Dictionary where Key == String
protocol StringProtocol {
    init(string s: String)
}

extension String: StringProtocol {
    init(string s: String) {
        self = s
    }
}

现在我们可以编写新的下标。 getter和setter的实现相当长,但它们应该很简单:我们从头到尾遍历键路径,然后在该位置获取/设置值:

// We want extension Dictionary where Key == String, but that's not supported yet,
// so work around it with Key: StringProtocol.
extension Dictionary where Key: StringProtocol {
    subscript(keyPath keyPath: KeyPath) -> Any? {
        get {
            guard let (head, remainingKeyPath) = keyPath.headAndTail() else {
                return nil
            }

            let key = Key(string: head)
            let value = self[key]
            switch remainingKeyPath.isEmpty {
            case true:
                // Reached the end of the key path
                return value
            case false:
                // Key path has a tail we need to traverse
                switch value {
                case let nestedDict as [Key: Any]:
                    // Next nest level is a dictionary
                    return nestedDict[keyPath: remainingKeyPath]
                default:
                    // Next nest level isn't a dictionary: invalid key path, abort
                    return nil
                }
            }
        }
        set {
            guard let (head, remainingKeyPath) = keyPath.headAndTail() else {
                return
            }
            let key = Key(string: head)

            // Assign new value if we reached the end of the key path
            guard !remainingKeyPath.isEmpty else {
                self[key] = newValue as? Value
                return
            }

            let value = self[key]
            switch value {
            case var nestedDict as [Key: Any]:
                // Key path has a tail we need to traverse
                nestedDict[keyPath: remainingKeyPath] = newValue
                self[key] = nestedDict as? Value
            default:
                // Invalid keyPath
                return
            }
        }
    }
}

这就是它的用法:

var dict: [String: Any] = [
    "countries": [
        "japan": [
            "capital": [
                "name": "tokyo",
                "lat": "35.6895",
                "lon": "139.6917"
            ],
            "language": "japanese"
        ]
    ],
    "airports": [
        "germany": ["FRA", "MUC", "HAM", "TXL"]
    ]
]

dict[keyPath: "countries.japan"] // ["language": "japanese", "capital": ["lat": "35.6895", "name": "tokyo", "lon": "139.6917"]]
dict[keyPath: "countries.someothercountry"] // nil
dict[keyPath: "countries.japan.capital"] // ["lat": "35.6895", "name": "tokyo", "lon": "139.6917"]
dict[keyPath: "countries.japan.capital.name"] // "tokyo"
dict[keyPath: "countries.japan.capital.name"] = "Edo"
dict[keyPath: "countries.japan.capital.name"] // "Edo"
dict[keyPath: "countries.japan.capital"] // ["lat": "35.6895", "name": "Edo", "lon": "139.6917"]

我真的很喜欢这个解决方案。这是相当多的代码,但你只需要编写一次,我认为它看起来非常好用。

答案 4 :(得分:0)

将你的字典传递给这个函数,它会返回一个扁平的字典,没有任何嵌套的字典。

// SWIFT 3.0

$lookup