5

友元是 C++ 中的概念,包含友元函数和友元类。被某个类声明为友元的函数或类可以访问这个类的私有成员。友元的正确使用能提高程序的运行效率,但同时也破坏了类的封装性和数据的隐藏性,导致程序可维护性变差。因此,除了 C++ 外很难再看到友元语法特性。

提出问题

但是友元并非一无是处,在某些时候确实有这样的需求。举例来说,现在我们需要定义一个 User 类,为了避免 User 对象在使用过程中属性被修改,需要将它设计成 Immutable 的。到目前为止还没有什么问题,但接下来问题来了——由于用户信息较多,其属性设计有十数个,为了 Immutable 全部通过构造方法的参数来设置属性是件让人悲伤的事情。

那么一般我们会想到这样几个方案:

方案简述

方案一,使用参数对象

这是 JavaScript 中常用的做法,使用参数对象,在构造 User 的时候,通过参数对象提供所有设置好的属性,再由 User 的构造方法从参数里把这些属性拷贝出来设置给只读成员。那么实现可能像这样:

为了简化代码,只定义了 IdUsernameName 三个属性。下同。

public sealed class User {
    public ulong Id { get; }
    public string Username { get; }
    public string Name { get; }
 
    public User(Properties props) {
        Id = props.Id;
        Username = props.Username;
        Name = props.Name;
    }

    public sealed class Properties {
        public ulong Id;
        public string Username;
        public string Name;
    }
}

一个属性就需要重复写三遍,如果代码是按行付费,这个定义会非常赚!

一次性设置

这种做法是自定义属性的 set 函数,或者定义一个 SetXxxxx 方法,判断如果值为 null 则可以设置,一但设置将不能再设置(理论上来说应该抛异常,但这里示例简化为无作为)。

下面的示例通过 UsernameName 演示了一次性设置的两种方法

public class User {
    public ulong Id { get; }

    public string Username { get; private set; }

    public void SetUsername(string username) {
        if (Username == null) {
            Username = username;
        }
    }

    public string Name {
        get {
            return name;
        }
        set {
            if (name == null) {
                name = value;
            }
        }
    }
    private string name;
 
    public User(ulong id) {
        Id = id;
    }
}

这种方法中的 User 并非 Immutalbe,只是近似,因为它的属性不能从“有”到“无”,却可以从“无”到“有”。

而且,我发现这个方法比上一个方法更赚钱。

Builder

Builder 模式嘛,就是为了解决初始化复杂对象问题的。

public class User {
    public ulong Id { get; }
    public string Username { get; internal set; }
    public string Name { get; internal set; }

    public User(ulong id) {
        Id = id;
    }
}

public class UserBuilder {
    private readonly User user;

    public UserBuilder(ulong id) {
        user = new User(id);
    }

    public UserBuilder SetUsername(string username) {
        user.Username = username;
    }

    public UserBuilder SetName(string name) {
        user.Name = name;
    }
    
    public User Build() {
        // 验证 user 的属性
        // 或者对某个属性进行一些后期加工(比如计算,格式化处理……)
        return user;
    }
}

为了避免外部访问,User 的各属性(除 Id)的 setter 都声明为 internal 的,因为只有这样 UserBuilder 才能调用它们的 setter

显然,采用这种方式在同一个 Assembly 中,比如 App Assembly 中,User 的属性仍然未能得到保护。

内部类实现“友元”特性

基于上面 Builder 模式的解决方案,很容易想到,如果把 UserBuilder 定义为 User 的内部类(嵌套类),那它直接就可以访问 User 的私有成员,其形式如下

public class User {
    // ....

    public class UserBuilder {
        // ....
    }
}

这其实和 C++ 的友元类语法还是有相似之处——就是都需要在 User 内部去声明,C++ 是声明友元,C# 则在声明的同时进行了定义

// C++ 代码
class UserBuilder;
class User {
    friend class UserBuilder;
}

class UserBuilder {
    // ....
}

内部类实现 Builder 模式

结构上没有问题了。再利用 C# 的分部类(partial class) 特性将 User 类和 UserBuilder 类分别写在两个源文件中,然后简化一下 UserBuilder 的名称,简化为 Builder,因为它定义在 User 的内部,语义已经非常明确了。

// User.cs
public sealed partial class User {
    ulong Id { get; }
    public string Username { get; private set; }
    public string Name { get; private set; }
    
    public User(ulong id) {
        Id = id;
    }
    
    public static Builder CreateBuilder(ulong id) {
        return new Builder(id);
    }
}
// User.Builder.cs
partial sealed class User {
    public class Builder {
        private readonly User user;
        
        public Builder(ulong id) {
            user = new User(id);
        }
        
        public Builder SetUsername(string username) {
            user.Username = username;
            return this; 
        }
        
        public Builder SetName(string name) {
            user.Name = name;
            return this;
        }
        
        public User Build() {
            // 验证和后期加工
            return user;
        }
    }
}

上面这段代码就达到了 Immutable User 的目的,同时代码还很优雅,通过分部类拆分源文件,代码结构也很清晰。不过还有一点小小的瑕疵……Build() 可以重复调用,而且在调用之后仍然可以修改 user 的属性。

再严谨一点

可重复使用的 Builder

如果想把 Build() 变成可多次调用,每次调用生成新的 User 对象,同时生成的 User 对象不受之后 BuilderSetXxxx 影响,可以在 Build() 的时候,产生一个 user 的复本返回。

另外,由于每个 User 对象的 Id 应该不同,所以由生成 CreateBuilder 的时候指定改为 Build() 的时候指定:

public partial class User {
    // ....
    public static Builder CreateBuilder()) {
        return new Builder();
    }
}

partial class User {
    public class Builder {
        private readonly User user;
    
        public Builder() {
            user = new User(0);
        }

        // ....
        
        public User Build(ulong id) {
            var inst = new User(id);
            inst.Username = user.Username;
            inst.Name = user.Name;
            return inst;
        }
    }
}

其实这里 Builder 内部的 user 被当作参数对象使用了。

一次性 Builder

一次性 Builder 相对简单一些,不需要在 Build() 的时候去拷贝属性。

partial class User {
    public class Builder {
        private User user;      // 这里 user 不再是 readonly 的
    
        public Builder(ulong id) {
            user = new User(id);
        }

        // ....
        
        public User Build() {
            if (user == null) {
                throw new InvalidOperationException("Build 只能调用一次")
            }

            // 验证和后期加工
            var inst = user;
            user = null;         // 将 user 置 null
            return inst;
        }
    }
}

一次性 BuilderBuild() 之后将 user 设置为 null,那么再调用所有 SetXxxx 方法都会抛空指针异常,而再次调用 Build() 方法则会抛 InvalidOperationException 异常。

小结

其实这个很普通的 C# 的内部类实现。但它确实可以解答“C# 中没有友元怎么办”这之类的问题。Java 中也可以类似的实现,只不过 Java 没有分部类,所以代码都得写在一个源文件里,这个源文件可能会很长很长……


边城
59.8k 声望29.6k 粉丝

一路从后端走来,终于走在了前端!