用C++实现C#的属性(Properties)

设想这样一个场景,我们正在写一个通过代码生成markdown的脚本

markdown中行内代码的语法为`code`,相应显示为code

对应到编码中,我们可以创建一个InlineCode类维护这种markdown元素

考虑到反引号只在输出markdown时需要,且反引号内的内容可能频繁修改

所以我们用一个string成员变量_text维护反引号内的内容(code),而不是整个行内代码(`code`)

而且修改内容后,我们可能还有些其他操作,比如说检查内容语法正确性啥的whatever

反正我们现在最好把_text变成私有的,然后定义set_textget_formatted_text成员函数读写内容

但这样代码的可读性就有点太好了 XD

如果我们只用暴露一个成员text给使用者,让text可以像一个string一样被使用,也就是它被读写时自动调用set_textget_formatted_text就好了

C#原生支持这种代理模式,我们在C#中可以将InlineCode类实现如下

public class InlineCode
{
    private string _text; 
    private void _notify(string old, string newer)
    { Console.WriteLine($"InlineCode.Text modified from `{old}` to `{newer}`");}

    public string Text
    {
        get => $"`{_text}`";
        set {  _notify(_text, value); _text = value; }
    }

    public string Style, Dom; 
	// and may have other members, omit
    public InlineCode() { }
};

C# PropertiesMSLearn上的介绍

简单来说,对于一个类型为TC#字段Prop

get必须满足委托类型(也就是函数签名+返回值类型)为T()set委托也必须为void(T)的形式

当读取Prop时,返回get(),当赋值Prop时,调用set(value),其中value是赋值表达式右手侧

所以上述代码中,Text属性get返回带反引号的内容,注意setvalue形参的声明在语法上省略了

class Program
{
    static void Main(string[] args)
    {
        var code = new InlineCode();
        code.Text = "Rust";
        Console.WriteLine(code.Text);
    }
}

所以使用InlineCode类时,只用关注内部文本内容,上述代码可以得到以下期望结果

InlineCode.Text modified from `` to `Rust`
`Rust`

现在考虑以下类似机制在C++中的实现

第1版代码:

template <typename T> class Field {
  using Getter = std::function<T()>;
  using Setter = std::function<void(const T &)>;
  Getter _get;
  Setter _set;

public:
  explicit Field( Getter getter = {}, Setter setter = {})
      : _get{getter},
        _set{setter} {}

  T get() { return _get(); }
  operator T() { return _get(); }

  Field &operator=(const T &arg) {
    _set(arg);
    return *this;
  }
};

创建代理类Field<T>,参考C#,将get的类型Getter定义为std::function<T()>,将get的类型Getter定义为std::function<void(const T &)>

这样,只要分别用一个_get_set变量保存Getter/Setter

然后再定义T get()并重载它的operator T() 用于读取(_get()),重载Field &operator=(const T &arg)用于修改_set(arg)

在函数体中的使用:

int main() {
  string str = "default";
  auto quoted_getter = [&]( ) { return "`" + str + "`"; };
  auto quoted_setter = [&](const string&s){ cout << "modified from `" << str << "` to `" << s << "`\n"; str = s;};
  Field<string> quoted( quoted_getter, quoted_setter); 
  quoted = string{"Rust"};
  cout << string{quoted} << endl;
}
// output:
//modified from `default` to `Rust`
//`Rust`

当然这里有个问题,using Setter = std::function<void(const T &)>;固定了了Setter的形参类型,我们甚至没有机会去重载一个void(T &&)版本的Setter用来区分实参的值类型

嗯,在C++里左右值引用是不同的变量类型,而在有GCC#上,引用并无左右引用而言

所以具体地,在main()6行,我们向Field<string>::operator=(const string&)传入了纯右值string{"Rust"},但函数内部不知情,不能移动string来节省开销

所以接下来把Setter/Getter类型而不是被代理的类型T作为模板参数

第2版代码:

template<typename Getter, typename Setter>
class Field {
    Getter _get;
    Setter _set;
    using GetterResT = std::invoke_result_t<Getter>;
public:
    explicit Field(
            Getter getter = {},
            Setter setter = {}
    ) : _get{getter}, _set{setter} {}

    GetterResT get() { return _get(); }
    operator GetterResT() { return _get(); }

    template<typename Arg>
    Field &operator=(Arg &&arg) {
        _set(std::forward<Arg>(arg));
        return *this;
    }
};

其中

  1. 使用std::invoke_result_t<Getter>获取_get()返回值的类型
  2. operator=修改为模板函数,可以同时处理左右值了

在函数体中的使用:

int main() {
  string str = "default";
  auto quoted_getter = [&]( ) { return "`" + str + "`"; };
  auto quoted_setter = [&](auto &&s){ 
    cout << "modified from `" << str << "` to `" << s << "`\n"; 
    str = std::forward<decltype(s)>(s);
    };
  Field quoted( quoted_getter, quoted_setter); 
  quoted = string{"Rust"};
  cout << string{quoted} << endl;
}
// output:
//modified from `default` to `Rust`
//`Rust`

注意这里setter(_set)如果需要的话,定义为一个模板函数来处理左右值

..完成一半了,接下来考虑Field作为类中成员变量如何使用

继续使用lambda作为getter/setter就不太可行了

因为定义类时要写明Getter/Setter类别,但我们拿不到lambda类型

因此改用具名的函数对象来捕获this指针

InlineCode类在C++中可以实现如下

struct InlineCode {
private:
    std::string _text;
    struct TextGetter {
        InlineCode *self;
        std::string operator()() {
            return "`" + self->_text + "`";
        }
    }  ;
    struct TextSetter {
        InlineCode *self;
        template<typename Str>
        void operator()(Str &&s) {
            cout << "modified from `" << self->_text << "` to `" << s << "`\n";
            self->_text = std::forward<decltype(s)>(s);;
        }
    };
public:
    Field<TextGetter, TextSetter> text;
    InlineCode(): _text{}, text{{this}, {this}}{}
};

使用:

InlineCode code;
code.text = string{"Rust"};
cout << string{code.text} << endl;

看起来类的命名因为Getter/Setter的后缀有些乱了,那再举一个栗子优化下代码结构

假设类型SomeClass有一个属性timegetter返回带前缀的时间stringsetter接收时间stringUNIX时间戳(long long)

实现如下

template<typename T> using FieldAs = Field<typename T::Getter, typename T::Setter>;struct SomeClass {
    std::string _time;
    struct Time {
        struct Getter {
            SomeClass *self;
            std::string operator()() {
                return string{"time here is "} + self->_time;
            }
        };
        struct Setter {
            SomeClass *self;
            template<typename Arg>
            void operator()(Arg &&arg) {
                using ValT = std::decay_t<Arg>;
                if constexpr (std::is_convertible_v<ValT, std::string_view>)
                    self->_time = std::forward<Arg>(arg);
                else {
                    std::tm *tm = std::localtime(&arg);
                    std::stringstream ss;
                    ss << std::put_time(tm, "%Y-%m-%d %H:%M:%S");
                    self->_time = ss.str();
                }
            }
        };
    };
    FieldAs<Time> time;
    SomeClass() : time{{this},
                       {this}} {};
}; 

首先我们定义一个模板别名

template<typename T> using FieldAs = Field<typename T::Getter, typename T::Setter>;

这样我们先在SomeClass内部定义属性对应的类型TimeTime内部再定义Getter/Setter类型,最后使用 FieldAs<Time>定义成员变量就行,这样命名更顺眼一点

使用:

int main() {
    SomeClass that;
    that.time = "2023-10-02";
    cout << string{that.time} << endl;
    that.time = std::int64_t{1696232071};
    cout << string{that.time} << endl;
}
// output:
//time here is 2023-10-02
//time here is 2023-10-02 15:34:31

Full code:

Github Gist

当然我认为使用Properties模式时,GetterSetter内部逻辑不能太复杂,否则整体代码可读性太差