Swift 中的相互非可选引用循环

Mutual non-optional reference cycle in Swift

考虑以下用例:

在某些游戏的模型中,您有一个 Player class。每个 Player 都有一个 unowned let opponent: Player 代表他们正在对战的对手。它们总是成对创建,并且 Player 必须始终有一个 opponent,因为它是非可选的。然而,这很难建模,因为一个玩家必须先于另一个玩家创建,而第一个玩家只有在创建第二个玩家之后才会有对手!

通过一些丑陋的黑客攻击,我想出了这个解决方案:

class Player {
    private static let placeholder: Player = Player(opponent: .placeholder, name: "")

    private init(opponent: Player, name: String) {
        self.opponent = opponent
        self.name = name
    }

    unowned var opponent: Player
    let name: String

    class func getPair(named names: (String, String)) -> (Player, Player) {
        let p1 = Player(opponent: .placeholder, name: names.0)
        let p2 = Player(opponent: p1, name: names.1)
        p1.opponent = p2
        return (p1, p2)
    }
}

let pair = Player.getPair(named:("P1", "P2"))

print(pair.0.opponent.name)
print(pair.1.opponent.name)

效果很好。但是,我无法将 opponent 转换为常量。一种解决方案是使 opponent 成为没有 set 的计算 属性,由私有 var 支持,但我想避免这种情况。

我试图用 Swift 指针进行一些黑客攻击,并想出了:

class func getPair(named names: (String, String)) -> (Player, Player) {
    var p1 = Player(opponent: .placeholder, name: names.0 + "FAKE")
    let p2 = Player(opponent: p1, name: names.1)

    withUnsafeMutablePointer(to: &p1) {
        var trueP1 = Player(opponent: p2, name: names.0)
        [=12=].moveAssign(from: &trueP1, count: 1)
    }
    return (p1, p2)
}

但这会产生段错误。此外,在使用 lldb 进行调试时,我们可以看到在 p1 初始化之后,我们有:

(lldb) p p1
(Player2.Player) $R3 = 0x0000000101004390 {
  opponent = 0x0000000100702940 {
    opponent = <uninitialized>
    name = ""
  }
  name = "P1FAKE"
}

但是在函数的末尾,lldb 显示如下:

(lldb) p p1
(Player2.Player) $R5 = 0x00000001010062d0 {
  opponent = 0x00000001010062a0 {
    opponent = 0x0000000101004390 {
      opponent = 0x0000000100702940 {
        opponent = <uninitialized>
        name = ""
      }
      name = "P1FAKE"
    }
    name = "P2"
  }
  name = "P1"
}

(lldb) p p2
(Player2.Player) $R4 = 0x00000001010062a0 {
  opponent = 0x0000000101004390 {
    opponent = 0x0000000100702940 {
      opponent = <uninitialized>
      name = ""
    }
    name = "P1FAKE"
  }
  name = "P2"
}

所以 p1 正确指向 p2,但是 p2 仍然指向旧的 p1。更何况,p1居然换地址了!

我的问题有两个:

  1. 是否有更简洁、更'Swifty'的方法来创建这种相互非可选引用的结构?

  2. 如果不是,我对Swift中的UnsafeMutablePointer之类的东西有什么误解导致上面的代码不起作用?

我认为隐式解包可选 是您想要的。您用感叹号 (!) 声明它。这是对编译器的承诺,即使 属性 可能在 init 调用期间被初始化,但在您使用它时它将具有有效值。把这个和私有的setter结合起来,就可以达到你想要的效果:

class Player: CustomStringConvertible {
    var name: String
    private(set) weak var opponent: Player!

    init(name: String) {
        self.name = name
    }

    class func getPair(named names: (String, String)) -> (Player, Player) {
        let p1 = Player(name: names.0)
        let p2 = Player(name: names.1)

        p1.opponent = p2
        p2.opponent = p1
        return (p1, p2)
    }

    var description: String {
        return self.name
    }
}

let (p1, p2) = Player.getPair(named: ("Player One", "Player Two"))
print(p1.opponent) // Player Two
print(p2.opponent) // Player One

由于 setter 是私有的,如果您尝试更改它,编译器将抛出错误:

let p3 = Player(name: "Player Three")
p1.opponent = p3 // Error: Cannot assign to property: 'opponent' setter is inaccessible

请注意,由于您希望 getPair 成为创建 Player 实例的单一方法,因此您最好将 init 调用设置为私有,因为它不会设置 opponent 属性:

private init(name: String) {
    // ...
}

在弄乱了一段时间之后,你想做的事情似乎是不可能的,而且与 Swift 并不相符。更重要的是,这可能是一种有缺陷的方法。

就 Swift 而言,初始化程序需要在 return 之前初始化所有存储的值。这是出于多种原因,我将不赘述。当值在初始化时不能 guaranteed/calculated 时,使用可选值、IUO 和计算值。如果您不想要 Optionals、IUO 或计算值,但仍希望在初始化后取消设置某些存储值,那么您也想吃蛋糕。

就设计而言,如果您需要两个 object 紧密链接以致于在初始化时需要彼此,那么您的模型 (IMO) 已损​​坏。这正是分层数据结构很好地解决的问题。在您的具体示例中,很明显您需要某种 Match 或 Competition object 来创建和管理两个玩家之间的关系,我知道您的问题更接近 "is this possible" 而不是 "should it be done" ,但我想不出这不是一个坏主意的任何情况。它从根本上破坏了封装。

Player object 应该管理和跟踪存在于 Player object 中的事物,并且 Player class 中唯一管理的关系应该是 children ].任何兄弟关系都应该是 accessed/set 到 parent。

这成为一个更明显的规模问题。如果你想添加第三个玩家怎么办? 50个呢?然后,您必须先初始化并将每个播放器连接到其他播放器,然后才能使用任何播放器。如果你想添加或删除一个玩家,你必须同时为每个连接的玩家做这件事,并阻止任何事情发生。

另一个问题是它在任何其他情况下都无法使用。如果设计得当,Player 可以用于所有类型的游戏。而目前的设计只允许在 1v1 的情况下使用。对于任何其他情况,您将不得不 re-write 它并且您的代码库会有所不同。

总而言之,您想要的东西在 Swift 中可能是不可能的,但如果或当它成为可能时,无论如何这几乎肯定是个坏主意:)

抱歉写了这篇文章,希望对您有所帮助!

在 Swift 中有一种方法可以使用惰性属性(为了方便 API)和包含两个播放器的容器(为了理智的内存管理)来干净地完成此操作。对于 TL;DR,请查看下面的示例代码。如需更长的答案,请继续阅读:

根据定义,在 Swift 中,两个对象之间的循环本质上必须是可选的,因为:

  1. Swift 指示对象的所有字段都需要在对象的初始化程序执行时初始化。因此,如果您想将两个对象与引用联系在一起(都需要初始化,因此至少有一个存在于其对手之前),可选的或隐式解包的可选引用或无主引用是您的选择。
  2. 如果对象是 class 类型,那么它们应该被弱引用,并且弱引用根据定义在本质上是可选的(自动归零和隐式或显式)。

能够像您所追求的那样创建一对动态分配的对象在具有垃圾收集器的环境中确实更加自然(Swift 使用自动引用计数,如果它只是泄漏您的对象对从您的代码中移除)。因此,某种包含两个玩家的容器在 Swift.

中很有用(如果不是绝对必要的话)

我认为,尽管语言限制阻止您在初始化时执行您正在尝试的操作,但您的模型还有其他问题,这些问题将受益于两级层次结构。

  • 如果一个玩家只存在于另一个玩家的上下文中,那么您每场比赛最多只能创建两个。
  • 您可能还想为玩家定义一个顺序,例如,如果是回合制游戏,则决定谁开始,或者为了演示目的定义其中一名玩家进行 "home" 比赛等

以上两个问题,尤其是第一个,确实清楚地指出了某种容器对象的实用性,该对象将处理您的播放器的初始化(即只有该容器知道如何初始化播放器,并且将能够将所有可变属性绑定在一起)。下面示例代码中的这个容器(匹配)是我放置一个 opponent(for:Player) 方法来查询玩家的对手的容器。此方法在 Player.

的惰性 opponent 属性 中被调用
public class Match {

    public enum PlayerIndex {
        case first
        case second
    }

    private(set) var players:PlayerPair

    init(players:PlayerNamePair) {
        // match needs to be first set to nil because Match fields need setting before 'self' can be referenced.
        self.players = (Player(match: nil, name: players.A, index: .first),
                        Player(match: nil, name: players.A, index: .second))

        // then set the 'match' reference in the Player objects.
        self.players.A.match = self
        self.players.B.match = self
    }

    public func opponent(for player:Player) -> Player {
        switch (player.index) {
        case .first:
            return self.players.B

        case .second:
            return self.players.A
        }
    }

    /* Player modelled here as a nested type to a Match.
     * That's just a personal preference, but incidental to the question posted. */

    typealias PlayerNamePair = (A:String, B:String)
    typealias PlayerPair = (A:Player, B:Player)

    public class Player {
        public let name:String

        fileprivate let index:PlayerIndex
        fileprivate weak var match:Match?

        /* This init method is only visible inside the file, and only called by Match initializer. */
        fileprivate init(match:Match?, name:String, index:PlayerIndex) {
            self.name = name
            self.match = match
            self.index = index
        }

        /* We dare implicitly unwrap here because Player initialization and lifecycle
        * is controlled by the containing Match.
        *
        * That is, Players only ever exists in context of an owning match,
        * therefore it's OK to treat it as a bug which crashes reproducibly
        * if you query for the opponent for the first time only after the match (which we know to have been non-nil) has already been deallocated. */
        public lazy var opponent:Player = public lazy var opponent:Player = self.match!.opponent(for: self)
    }
}