如何编写C++getter和setter

How to write C++ getters and setters

本文关键字:setter C++getter 何编写      更新时间:2023-10-16

如果我需要写一个二传手和/或getter,我就这样写:

struct X { /*...*/};
class Foo
{
private:
X x_;
public:
void set_x(X value)
{
x_ = value;
}
X get_x()
{
return x_;
}
};

然而,我听说这是编写setter和getter的Java风格,我应该以C++风格编写它。此外,我被告知这是不熟练的,甚至是不正确的。那是什么意思?如何在C++中编写二传手和二传手?


假设对吸气剂和/或二传手的需求是合理的。 例如,也许我们在二传手中进行一些检查,或者我们只编写吸气器。

有很多关于不需要getter和setter的喋喋不休。虽然我同意这里所说的大部分内容,但我仍然认为需要知道如何习惯性地编写这些方法,因为有正当的理由认为getter和setter是正确的解决方案。乍一看,他们可能不会被视为二传手或二传手,但他们确实如此,或者至少编写它们的模式适用。

例如:

  • 获取向量的大小。您不希望公开数据成员,因为它需要是只读的。

  • 目录和资源库不需要只公开数据成员。考虑获取和设置数组的元素。那里有逻辑,你不能只公开一个数据成员,首先没有要公开的数据成员。它仍然是您无法避免的吸气手/二传手组合:

    class Vector
    {
    void set_element(std::size_t index, int new_value);
    int get_element(std::size_t index);
    };
    

    了解写 getter 和 setter 的C++惯用方式将使我能够以C++惯用的方式编写上述get_element/set_element

标准库中出现了两种不同形式的"属性",我将它们归类为"面向身份"和"面向价值"。选择哪个取决于系统应如何与Foo交互。两者都不是"更正确"。

面向身份

class Foo
{
X x_;
public:
X & x()       { return x_; }
const X & x() const { return x_; }
}

在这里,我们返回对基础X成员的引用,这允许调用站点的双方观察由另一方发起的更改。X成员对外界是可见的,大概是因为它的身份很重要。乍一看,属性似乎只有"get"一面,但如果X是可分配的,则情况并非如此。

Foo f;
f.x() = X { ... };

以价值为导向

class Foo
{
X x_;
public:
X x() const { return x_; }
void x(X x) { x_ = std::move(x); }
}

在这里,我们返回X成员的副本,并接受要覆盖的副本。任何一方的后续更改都不会传播。在这种情况下,我们只关心x的价值

多年来,我开始相信getter/setter的整个概念通常是一个错误。尽管听起来相反,但公共变量通常是正确答案。

诀窍是公共变量应该是正确的类型。在问题中,您指定了要么我们编写了一个 setter 来对正在写入的值进行一些检查,要么我们只编写了一个 getter(所以我们有一个有效的const对象)。

我想说的是,这两个基本上都是这样说的:"X是一个整数。只是它不是真正的 int - 它真的有点像int,但是有这些额外的限制......">

这就引出了真正的问题:如果仔细观察 X 表明它确实是一种不同的类型,那么定义它真正的类型,然后将其创建为该类型的公共成员。它的裸骨可能看起来像这样:

template <class T>
class checked {
T value;
std::function<T(T const &)> check;
public:
template <class checker>
checked(checker check) 
: check(check)
, value(check(T())) 
{ }
checked &operator=(T const &in) { value = check(in); return *this; }
operator T() const { return value; }
friend std::ostream &operator<<(std::ostream &os, checked const &c) {
return os << c.value;
}
friend std::istream &operator>>(std::istream &is, checked &c) {
try {
T input;
is >> input;
c = input;
}
catch (...) {
is.setstate(std::ios::failbit);
}
return is;
}
};

这是通用的,因此用户可以指定类似函数的东西(例如,lambda)来确保值是正确的 - 它可能会传递值不变,或者可能会修改它(例如,对于饱和类型)或者它可能抛出异常 - 但如果它没有抛出,它返回的值必须是指定类型可接受的值。

因此,例如,要获得一个只允许 0 到 10 之间的值,并在 0 和 10 处饱和的整数类型(即,任何负数变为 0,任何大于 10 的数字变为 10),我们可以按照以下一般顺序编写代码:

checked<int> foo([](auto i) { return std::min(std::max(i, 0), 10); });

然后我们可以或多或少地做通常的事情foo,并保证它将始终在 0..10 范围内:

std::cout << "Please enter a number from 0 to 10: ";
std::cin >> foo; // inputs will be clamped to range
std::cout << "You might have entered: " << foo << "n";
foo = foo - 20; // result will be clamped to range
std::cout << "After subtracting 20: " << foo;

有了这个,我们可以安全地公开成员,因为我们定义的类型实际上是我们希望它成为的类型 - 我们想要施加的条件是类型所固有的,而不是事后(可以这么说)由getter/setter附加的东西。

当然,这是针对我们希望以某种方式限制值的情况。如果我们只想要一个有效的只读类型,那就容易多了——只是一个定义构造函数和operator T的模板,而不是一个将 T 作为其参数的赋值运算符。

当然,某些限制输入的情况可能更复杂。在某些情况下,您希望两件事之间的关系,因此(例如)foo必须在 0..1000 范围内,bar必须在 2x 到 3xfoo之间。有两种方法可以处理这样的事情。一种是使用与上面相同的模板,但底层类型是std::tuple<int, int>,然后从那里开始。如果您的关系非常复杂,则最终可能希望完全定义一个单独的类来定义该复杂关系中的对象。

总结

将你的成员定义为你真正想要的类型,并且getter/setter可以/将要做的所有有用的事情都包含在该类型的属性中。

这就是我编写通用 setter/getter 的方式:

class Foo
{
private:
X x_;
public:
X&       x()        { return x_; }
const X& x() const  { return x_; }
};

我将尝试解释每个转换背后的原因:

版本的第一个问题是,与其传递值,不如传递常量引用。这避免了不必要的复制。没错,因为C++11可以移动该值,但这并不总是可能的。对于基本数据类型(例如int) 使用值而不是引用是可以的。

所以我们首先纠正这一点。

class Foo1
{
private:
X x_;
public:
void set_x(const X& value)
//             ^~~~~  ^
{
x_ = value;
}
const X& get_x()
//  ^~~~~  ^
{
return x_;
}
};

上述解决方案仍然存在问题。由于get_x不会修改对象,因此应将其标记为const。这是称为常量正确性的C++原则的一部分。

上述解决方案不会让您从const对象获取属性:

const Foo1 f;
X x = f.get_x(); // Compiler error, but it should be possible

这是因为get_x不是 const 方法,则不能在 const 对象上调用。这样做的理由是,非 const 方法可以修改对象,因此在 const 对象上调用它是非法的。

因此,我们进行了必要的调整:

class Foo2
{
private:
X x_;
public:
void set_x(const X& value)
{
x_ = value;
}
const X& get_x() const
//                   ^~~~~
{
return x_;
}
};

上述变体是正确的。然而,在C++还有另一种编写方式,它更C++,更少Java。

有两件事需要考虑:

  • 我们可以返回对数据成员的引用,如果我们修改该引用,我们实际上会修改数据成员本身。我们可以用它来编写我们的二传手。
  • 在C++方法可能仅因一致性而过载。

因此,有了上述知识,我们可以编写最终的优雅C++版本:

最终版本

class Foo
{
private:
X x_;
public:
X&       x()        { return x_; }
const X& x() const  { return x_; }
};

作为个人喜好,我使用新的尾随返回函数样式。(例如,我写auto foo() -> int而不是int foo().

class Foo
{
private:
X x_;
public:
auto x()       -> X&       { return x_; }
auto x() const -> const X& { return x_; }
};

现在我们将调用语法从:

Foo2 f;
X x1;
f.set_x(x1);
X x2 = f.get_x();

自:

Foo f;
X x1;
f.x() = x1;
X x2 = f.x();
const Foo cf;
X x1;
//cf.x() = x1; // error as expected. We cannot modify a const object
X x2 = cf.x();

超越最终版本

出于性能原因,我们可以更进一步,重载&&并返回对x_的右值引用,从而允许在需要时从它移动。

class Foo
{
private:
X x_;
public:
auto x() const& -> const X& { return x_; }
auto x() &      -> X&       { return x_; }
auto x() &&     -> X&&      { return std::move(x_); }
};

非常感谢评论中收到的反馈,特别是StorryTeller对改进这篇文章的强烈建议。

您的主要错误是,如果您在 API 参数和返回值中不使用引用,那么您可能会冒着在两个 get/set 操作中执行不需要的副本的风险("MAY",因为如果您使用优化器,您的编译可能能够避免这些副本)。

我会把它写成:

class Foo
{
private:
X x_;
public:
void x(const X &value) { x_ = value; }
const X &x() const { return x_; }
};

这将保持常量的正确性,这是C++的一个非常重要的功能,并且它与旧C++版本兼容(另一个答案需要 c++11)。

您可以将此类用于:

Foo f;
X obj;
f.x(obj);
X objcopy = f.x(); // get a copy of f::x_
const X &objref = f.x(); // get a reference to f::x_

我发现使用 get/set 在 _ 或驼峰大小写(即 getX()、setX())中都是多余的,如果你做错了什么,编译器会帮助你解决它。

如果要修改内部 Foo::X 对象,还可以添加 x() 的第三个重载:

X &x() { return x_; }

.. 通过这种方式,您可以编写类似以下内容的内容:

Foo f;
X obj;
f.x() = obj; // replace inner object
f.x().int_member = 1; // replace a single value inside f::x_

但我建议你避免这种情况,除非你真的需要经常修改内部结构(X)。

使用一些 IDE 进行生成。CLion 提供了基于类成员插入 getter 和 setter 的选项。从那里您可以看到生成的结果并遵循相同的做法。