从字典中删除嵌套键

10

假设我有一个相当复杂的字典,比如这个:

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"应该为nil。

这是我目前用于删除"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") 进行调用。

你的字典每次都一样,那我会为你提供一个解决方案。 - Jitendra Modi
dfri: 抱歉,实际上并不是这样的。我有很多其他事情要忙,但仍然试了一下你的答案(在 playground 中),结果没有起作用。我猜想可能是语法有些问题,应该是“extension Dictionary where Key: ExpressibleByStringLiteral, Value: Any”吧?无论如何,希望明天能花点时间再看看它。 - scrrr
5个回答

9

当使用下标时,如果下标是get/set类型且变量是可变的,则整个表达式就是可变的。然而,由于类型转换,该表达式“失去”了可变性。(它不再是l-value)。

最简单的解决方法是创建一个既是get/set类型又为您执行转换的下标。

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,在其中讨论它:更改未指定类型的字典。 链接

2

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

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

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
我想跟进我的上一个回答,提供另一种解决方案。这个方案扩展了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
    }
}

现在我们可以编写新的下标。获取器和设置器的实现相当冗长,但应该很直观:我们从开头到结尾遍历键路径,然后在该位置获取/设置值。
// 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"]

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

你认为使用可变参数比使用包含点作为分隔符的单个字符串更符合 "Swifty" 的风格吗?它可以防止一些打字错误,并且允许你的字典键包含句点。 - Tim Vermeulen
我不确定。我担心路径的层次结构在逗号分隔的列表中会丢失。但这肯定是一个替代方案。 - Ole Begemann

1
有趣的问题。问题似乎在于Swift的可选链机制通常可以改变嵌套字典,但会因必要的类型转换从到[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,如果是,则返回给定键/索引处的值。如果类型不匹配,例如尝试访问.string值的键,则它们将返回nil。下标还具有设置器。这对于使链接的突变起作用至关重要。
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")]))

0
将您的字典传递给此函数,它将返回一个平面字典,不包含任何嵌套字典。
// SWIFT 3.0
func concatDict( dict: [String: Any])-> [String: Any]{
        var dict = dict
        for (parentKey, parentValue) in dict{
            if let insideDict = parentValue as? [String: Any]{
                let keys = insideDict.keys.map{
                    return parentKey + $0
                }
                for (key, value) in zip(keys, insideDict.values) {
                    dict[key] = value
                }
                dict[parentKey] = nil
                dict = concatDict(dict: dict)
            }
        }
        return dict
    }

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接