65.9K
CodeProject 正在变化。 阅读更多。
Home

C++命名函数参数,又一个方法

2017年2月19日

CPOL

8分钟阅读

viewsIcon

21378

downloadIcon

173

一种相当疯狂但简单的命名函数参数方法

引言
名字有什么关系?我们称之为玫瑰的花
换个名字闻起来也会一样香

威廉·莎士比亚,《罗密欧与朱丽叶

动机

我遇到了多种不同的C++命名函数参数的方法。其中一些看起来很有趣,但我没有找到与我在JavaScript中提出的方法类似的东西。难怪人们还在寻找拥有命名参数的方法。这项功能非常有吸引力。

  • 人们不需要记住参数的顺序和含义;处理可理解的名称更符合人类的本性。
  • 调用语句的代码变得更具可读性;可以清楚地看到传递给方法的每个值所起的作用。
  • 在调用语句中,可以省略部分或全部命名参数,然后使用默认值。
  • 在同一个函数调用中,命名参数可以与位置参数结合使用。

我尝试研究在C++中做类似事情的可能性,结果发现它相当棘手。同时,在这项练习之后,调用方的用法看起来非常类似于我的JavaScript示例。我认为这件事至少有趣到值得分享。

免责声明:

我明白我提出的技术并不是真正的参数传递技术。它有限且甚至是虚假的,仅仅是对参数传递语法的文本模仿。我也明白它几乎没有实际意义,也没有理论重要性,并且很快就会被淘汰。

然而,它确实有效,无害且不太可能被滥用。它可以被视为玩弄语言表达能力的乐趣。

想法

第一个也是最简单的实现会这样做:

template <typename T>
class NamedParameter {
public:

    NamedParameter<void>* operator =(const T &value) {
        this->value = value;
        return nullptr;
    } //operator =

    operator T() const {
        return value;
    } //operator T()

private:

    T value;

};

这看起来可能有点奇怪。显然,使用void模板参数,如果用于变量成员的声明,将导致编译错误,因为成员T value,它不能与void T一起编译。这个小问题很容易解决;我稍后会展示。但是为什么使用NamedParameter<void>呢?在函数声明中,它充当占位符参数,标记在调用代码中可以传递一组命名参数的位置。它应该唤起“多态的无限大小命名参数数组”的想法。让我们看看它的用法可能是什么样的:

class Sample {
public:    
    NamedParameter<int> ip;
    NamedParameter<double> dp;
    void A(int a, double b, NamedParameter<void>*) {}
    void B(int a, NamedParameter<void>*, double b) {}
}; //class Sample

//...

static void Demo() {
    Sample s;
    // positional and named parameters can be mixed:
    s.A(1, 2.2, (s.ip = 3, s.dp = 4.4));
    // the order of named actual arguments doesn't matter:
    s.A(1, 2.2, (s.dp = 4.4, s.ip = 3));
    // ... and they don't need to be at the end:
    s.B(5, (s.dp = 6.6, s.ip = 7), 8.8);
    // round brackets are not needed if one passes only one value
    // to a named parameter:
    s.B(5, s.ip = 7, 8.8);
} //Demo

请注意,对NamedParameter<…>实例的赋值集包含在圆括号中。如果只向命名参数对象传递一个值,则不需要它。语法要求在函数调用中至少使用一个命名参数成员。该语法仅适用于class/struct成员,但这些函数和命名参数成员可以是实例成员或静态成员。

圆括号似乎是不可避免的。我曾考虑为NamedParameter定义逗号(,)运算符,但这似乎无济于事,因为它无法改变参数列表语法中以逗号分隔的非运算符语义,当代码放在实际参数列表中时,这种情况占主导地位。

同时,强制的圆括号可以被视为一种优势:在此语法中,传递命名参数值的赋值运算符集不必位于参数列表的末尾;此外,可以在同一个函数参数列表中定义更多这样的集合。

这种函数调用代码可以正确编译,因为NamedParameter赋值运算符被定义为返回=NamedParameter<void>*=;这样,它就匹配了形式参数定义。

默认函数参数机制就可以这样实现

class Sample {
public:

   Sample() { setDefaults(); }

    NamedParameter<int> ip;
    NamedParameter<double> dp;

    void A(int a, double b, NamedParameter<void>*) {
        // use parameters...
        setDefaults();
    } //A

    void B(int a, NamedParameter<void>*, double b) {
        // use parameters...
        setDefaults();
    } //B

private:

    static const int defaultIp = 0;
    double defaultDp = acos(-1);
    void setDefaults() {
        ip = defaultIp;
        dp = defaultDp;
    } //setDefaults

};

在此代码示例中,使用命名参数的函数保证在每次调用之前,未使用的参数都将具有其默认值。但是,这种保证可以通过在声明类外部显式赋值来破坏。同样,这可以被视为故意更改默认值的一种方式。

很明显,这种语法,或者说语法糖,是对参数传递机制非常粗糙的文本模仿。事实上,真正的机制只使用了赋值语义。所以这种糖可能不会那么甜。然而,它确实有效,并且如果声明类(上面示例中的Sample)的开发人员知道发生了什么,它也是相当有害的。但是,有一个方面可能看起来有点小问题。

事情是这样的:NamedParameter对象带有两个运算符,一个用于赋值,一个用于读取底层值;两者都可以从声明类外部访问。这可能被认为是值得商榷的。

当然,使用NamedParameter成员的声明类的开发人员可以始终避免读取参数value的任何副作用。这些值只能临时使用,避免对类内部进行任何不必要的访问。但是,命名参数成员应该是只写的,正如参数传递的语义所暗示的那样吗?

我尝试以两种不同的方式,在两个不同的命名空间中限制从声明类外部读取值;这两种方式都可以选择。

防止读取,可选:未分配异常

一种方法可以在运行时应用。

namespace Named {

    template <typename T>
    class Parameter {
    public:

        Parameter<void>* operator =(const T &value) {
            this->value = value;
            assigned = true;
            return nullptr;
        } //operator =

        operator T() const {
            if (!assigned)
                throw unassigned_named_parameter();
            return value;
        } //operator T()

        void unassign() { assigned = false; }

        struct unassigned_named_parameter : private std::logic_error {
            unassigned_named_parameter() : std::logic_error(std::string()) {}
        }; //unassigned_named_parameter

    private:

        T value;
        bool assigned = false;

    }; //class Parameter

    // This specialization is just a guard against members like
    // Parameter<void> parameter;
    template<> class Parameter<void> {};

}

首先,看看Named::Parameter<void>的模板特化。它将防止该类型变量/成员的声明失败编译,该声明无论如何都是无用的。这种专门化类型的唯一有用作用是定义函数的命名参数集。

通过将命名参数成员设为未分配并在尝试读取未分配状态的值时抛出异常,可以防止声明类外部的参数读取操作。它可以与默认函数参数机制结合使用。这种方法最好的地方在于它是可选的:如果从不调用unassign(),它就回到了上面展示的最简单的朴素实现。基于运行时的这种方法的缺点是显而易见的。充其量,它可以在开发过程中用作防傻措施。同时,它的好处在于简洁,特别是如果从不调用unassign()

有关用法示例,请参阅“NamedParametersDemo.h”。

另一种方法是使命名参数对象在声明类外部纯粹只写,但这也有一些小问题。

另一种选择:私有读取权限

因此,替代方法是将声明类的类型作为附加模板参数传递,并使其成为命名参数实现中的一个友元。请注意,这仅在C++11或更高标准下才可能。

在这种情况下,读取访问(operator T())可以设置为private

namespace NamedWriteonly {

    template <typename T, typename OWNER = void>
    class Parameter {
    public:

        Parameter<void>* operator =(const T &value) {
            this->value = value;
            return nullptr;
        } //operator =

    private:

        operator T() const {
            return value;
        } //operator T()

        T value;
        friend OWNER; // since C++11

    }; //class Parameter

    // This specialization is just a guard against members/variables like
    // NamedWriteOnly::Parameter<void> parameter;
    template<> class Parameter<void> {};

}

这种方法的一个小问题是声明类中的用法更复杂,不太明显,并且需要将类型作为模板参数传递。它的样子是这样的:

class PrivateReadSample {
public:

    NamedWriteonly::Parameter<int, PrivateReadSample> ip;
    NamedWriteonly::Parameter<double, PrivateReadSample> dp;

    void A(int a, NamedWriteonly::Parameter<void>*, double b) {
        aValue = a;
        bValue = b;
        ipValue = ip;
        dpValue = dp;
    } //A

    void ShowAssignmentResult() {
        printf(
            "Parameter a: %d; parameter ip: %d; parameter dp: %g; parameter b: %g\n",
            aValue, ipValue, dpValue, bValue);
    } //ShowAssignmentResult

private:

    int ipValue, aValue;
    double dpValue, bValue;

};

如果第二个模板参数不正确或丢失,声明本身将编译,但它将使命名参数值在应读取的位置不可读,从而使该机制几乎无用。

类型限制

底层命名参数类型T的一个明显限制是某些类删除了无参构造函数。该构造函数,无论默认与否,都应该存在,否则Parameter::value的声明将失败。

兼容性和构建

所有略有不同的替代解决方案都包含在以下两个文件之一中:“NamedParameters.h”或“NamedParametersWriteonly.h”。第一个也是最简单的朴素解决方案,仍然非常可用,可以从“ArticleCodeSamples.h”中获取。这些类可以添加到任何项目中。

编译器应支持 C++11 或更高版本。对于 GCC,这应该设置为 -std=c++11-std=c++14 等选项。

演示项目有两种形式:1) 使用Microsoft C++编译器和Clang的Visual Studio 2015解决方案和项目 — 请参阅“CppNamedParameters.sln”;2) 使用GCC的Code::Blocks项目 — “CppNamedParameters.cbp”。对于所有其他选项,可以通过在代码目录“CppNamedParameters”中添加所有“*.h”和“*.cpp”文件来组装项目或Makefile。

我已在 Visual Studio 2015、Clang 4.0.0、GCC 5.1.0 上测试了代码。

C++选项包括“禁用语言扩展”(Microsoft和Clang为/Za),这对于Microsoft似乎至关重要。然而,使用此选项,一个奇怪的Microsoft问题是在文件末尾无法编译“//”注释;该问题可以通过例如在文件末尾添加一个空行来解决;我在每个文件末尾设置了一个“Microsoft守卫”(以“/* … */”的形式)。

参考文献

  1. Marco Arena,为现代C++带来命名参数
  2. 更多C++惯用法/命名参数
  3. Justin Van Horne,使用运算符后缀和可变参数模板的C++11命名参数
  4. S A Kryukov,JavaScript函数的命名参数,又一个方法
  5. S A Kryukov,JavaScript函数的命名参数,第二部分:结构化
© . All rights reserved.