可选函数参数:使用默认参数(NULL)还是重载函数?

48

我有一个函数可以处理给定的向量,但如果没有给出则可能会创建这样的向量。

针对这种情况,即函数参数是可选的,我看到了两种设计选择:

将其变为指针并默认设置为 NULL

void foo(int i, std::vector<int>* optional = NULL) {
  if(optional == NULL){
    optional = new std::vector<int>();
    // fill vector with data
  }
  // process vector
}

或者有两个具有重载名称的函数,其中一个函数省略了参数:

void foo(int i) {
   std::vector<int> vec;
   // fill vec with data
   foo(i, vec);
}

void foo(int i, const std::vector<int>& optional) {
  // process vector
}

有没有理由更喜欢其中一个解决方案?

我稍微更喜欢第二个解决方案,因为我可以将向量作为const引用,因为它只会被读取而不会被写入。此外,接口看起来更清晰(NULL难道不是一种hack吗?)。由间接函数调用导致的性能差异可能已经被优化掉了。

然而,我经常在代码中看到第一个解决方案。除了程序员的懒惰之外,还有什么强有力的理由支持它吗?

12个回答

45
我不会采用任何一种方法。
在这种情况下,foo()的目的似乎是处理一个向量。也就是说,foo()的工作是处理这个向量。
但是在foo()的第二个版本中,它隐含地有了第二个任务: 创建向量。foo()版本1和foo()版本2之间的语义不同。
我建议只有一个foo()函数来处理向量,如果需要的话再编写另一个函数来创建向量。
例如:
void foo(int i, const std::vector<int>& optional) {
  // process vector
}

std::vector<int>* makeVector() {
   return new std::vector<int>;
}

显然,这些函数非常简单。如果makeVector()函数只需要调用new就能完成工作,那么拥有makeVector()函数可能没有任何意义。但我敢肯定,在你实际的情况中,这些函数所做的远不止这里展示的内容。上面的代码体现了一种基本的语义设计方法:让一个函数只做一件事情

我上面为foo()函数设计的方法还展现了我个人在设计接口(包括函数签名、类等)时所使用的另一种基本方法:我认为一个好的接口具备1)易于直观地正确使用 2)难以或不可能被错误使用。对于foo()函数,我们暗示着,通过我的设计,向量已经存在并准备好了。通过将foo()设计为取引用而不是指针,既可以很自然地让调用者明白他们必须已经拥有一个向量,也让他们难以传递还没有完全准备好的向量。


这是最好的建议。首先,简化。我很惊讶地发现它在答案堆栈的底部。 - dkretz
完全同意;在我看来是最佳答案。 - Graeme
但是版本2中向量的创建不可外部可见 - 因此除了接受额外参数之外,版本1和版本2的语义是相同的,对吗? - Jeremy

31

我肯定会支持第二种重载方法的做法。

第一种方法(可选参数)模糊了方法的定义,使其不再具有单一且明确定义的目的。这反过来增加了代码的复杂性,使其更难以让不熟悉它的人理解。

采用第二种方法(重载方法),每个方法都有明确的目的。每个方法都是良好结构化内聚的。一些额外的注意事项:

  • 如果存在需要重复到两个方法中的代码,则可以将其提取到单独的方法中,并让每个重载方法调用此外部方法。
  • 我还会进一步为每个方法命名,以指示各个方法之间的差异。这将使代码更加自描述。

11
我对这个答案有些满意,但我不同意你的推理。重载方法的重点是_每个方法已经完成了相同的工作_,这就是为什么我们不关心它们的名称区别的原因。通常情况下,重载只会在所接受的参数_类型_上有所不同——我想到了一组print( int )print( double )类型的函数。如果有一个可选的返回参数,那只意味着调用者可能有或没有兴趣接收该可选结果。我同意这个答案中的最后一句话:如果这些函数做不同的事情,那么__是的,请分别命名每个方法__。 - bobobobo

21

虽然我理解许多人关于默认参数和函数重载的抱怨,但似乎缺乏对这些特性所带来好处的理解。

默认参数值:
首先,我想指出,在项目的初期设计中,如果设计良好,应该几乎不需要使用默认值。然而,当涉及现有项目和成熟的API时,默认值的最大优势就体现出来了。我工作的项目由数百万行现有代码组成,没有重新编写所有代码的奢侈条件。因此,当您希望添加一个需要额外参数的新功能时,需要为新参数提供默认值。否则,您将打破所有使用您的项目的人的代码。这对我个人来说是可以接受的,但我怀疑您的公司或产品/API的用户会欣赏每次更新都必须重新编写他们的项目。 简而言之,对于向后兼容性,默认参数非常有用! 这通常是您会在大型API或现有项目中看到默认参数的原因。

函数重载: 函数重载的好处在于它们允许共享一个功能概念,但具有不同的选项/参数。然而,很多时候我看到函数重载被懒惰地用来提供截然不同的功能,仅仅是具有稍微不同的参数。在这种情况下,它们应该分别拥有特定功能的命名函数(如OP的示例)。

当这些C/C++的特性被正确使用时,它们是好的且工作良好的。这也适用于大多数编程特性。只有当它们被滥用/误用时,它们才会引起问题。

免责声明:
我知道这个问题已经过去几年了,但由于这些答案今天(2012年)出现在我的搜索结果中,我觉得需要进一步解释,以便为未来的读者提供参考。


6

我同意,我会使用两个函数。基本上,你有两种不同的用例,因此有两种不同的实现是有道理的。

我发现,我写的C++代码越多,我的参数默认值就越少 - 如果这个特性被弃用,我不会真的流泪,尽管我必须重写一堆旧代码!


完全同意第二段...除模板参数外,默认参数几乎从不是一个好的设计选择。 - Diego Sevilla
除了它们真的是构造函数的唯一选择(至少在C++'03中)。 - Richard Corden

6
在C++中,引用不能为NULL。一个非常好的解决方案是使用Nullable模板。这将使你能够在ref.isNull()情况下进行操作。
你可以使用以下代码:
template<class T>
class Nullable {
public:
    Nullable() {
        m_set = false;
    }
    explicit
    Nullable(T value) {
        m_value = value;
        m_set = true;
    }
    Nullable(const Nullable &src) {
        m_set = src.m_set;
        if(m_set)
            m_value = src.m_value;
    }
    Nullable & operator =(const Nullable &RHS) {
        m_set = RHS.m_set;
        if(m_set)
            m_value = RHS.m_value;
        return *this;
    }
    bool operator ==(const Nullable &RHS) const {
        if(!m_set && !RHS.m_set)
            return true;
        if(m_set != RHS.m_set)
            return false;
        return m_value == RHS.m_value;
    }
    bool operator !=(const Nullable &RHS) const {
        return !operator==(RHS);
    }

    bool GetSet() const {
        return m_set;
    }

    const T &GetValue() const {
        return m_value;
    }

    T GetValueDefault(const T &defaultValue) const {
        if(m_set)
            return m_value;
        return defaultValue;
    }
    void SetValue(const T &value) {
        m_value = value;
        m_set = true;
    }
    void Clear()
    {
        m_set = false;
    }

private:
    T m_value;
    bool m_set;
};

现在您可以拥有更加高效的IT技术。
void foo(int i, Nullable<AnyClass> &optional = Nullable<AnyClass>()) {
   //you can do 
   if(optional.isNull()) {

   }
}

还要看看std::optional - Sebastian Wagner

3
我通常避免第一种情况。请注意这两个函数在执行的操作上是不同的。其中一个将向量填充了一些数据,而另一个则没有(只是接受来自调用者的数据)。我倾向于为实际执行不同操作的函数命名不同的名称。事实上,即使在编写它们时,它们也是两个不同的函数:
  • foo_default(或者只是foo
  • foo_with_values
至少我发现这种区别在长期使用中更清晰,并且适用于偶尔使用库/函数的用户。

2

我完全赞同“过载”派的观点。其他人已经针对您的实际代码示例添加了具体细节,但我想补充一下使用重载相对于默认参数的优点。

  • 任何参数都可以被“默认化”。
  • 如果覆盖函数使用不同的默认值,也不会有什么问题。
  • 不需要为现有类型添加“hacky”构造函数,以便使它们具有默认值。
  • 可以默认输出参数,而无需使用指针或hacky全局对象。

以下是一些代码示例:

任何参数都可以被默认值化:

class A {}; class B {}; class C {};

void foo (A const &, B const &, C const &);

inline void foo (A const & a, C const & c)
{
  foo (a, B (), c);    // 'B' defaulted
}

不存在覆盖函数默认值的危险:

class A {
public:
  virtual void foo (int i = 0);
};

class B : public A {
public:
  virtual void foo (int i = 100);
};


void bar (A & a)
{
  a.foo ();           // Always uses '0', no matter of dynamic type of 'a'
}

不必为了允许默认类型而添加“hacky”构造函数到现有类型中:

struct POD {
  int i;
  int j;
};

void foo (POD p);     // Adding default (other than {0, 0})
                      // would require constructor to be added
inline void foo ()
{
  POD p = { 1, 2 };
  foo (p);
}

输出参数可以默认设置,无需使用指针或hacky全局对象:
void foo (int i, int & j);  // Default requires global "dummy" 
                            // or 'j' should be pointer.
inline void foo (int i)
{
  int j;
  foo (i, j);
}

唯一的例外是在构造函数中,由于当前不可能将构造函数转发到另一个构造函数,因此无法使用重载和默认值规则(我相信C++ 0x将解决这个问题)。


2

我也更喜欢第二种方法。虽然这两种方法之间没有太大的区别,但在 foo(int i) 这个重载中,你基本上是在使用原始方法的功能,而原始方法完全可以正常工作,不需要考虑其他方法的存在或缺失,因此在重载版本中有更多的关注点分离。


2

在C++中,尽可能避免使用有效的NULL参数。原因是它会大幅降低调用方文档的可读性。我知道这听起来很极端,但我经常使用需要传入10-20个参数的API,其中一半可以是NULL。结果导致的代码几乎无法阅读。

SomeFunction(NULL, pName, NULL, pDestination);

如果你将其切换为强制使用const引用,代码就会变得更易读。

SomeFunction(
  Location::Hidden(),
  pName,
  SomeOtherValue::Empty(),
  pDestination);

你说得对,给默认值命名(或者将其定义为0)比func(arg, 0, 0, 0, 0, 0, 0);更易于阅读。 - bobobobo

1
我更倾向于第三个选项: 将其分成两个函数,但不要重载。
重载本质上不太可用。它们要求用户意识到两个选项并弄清它们之间的区别,如果他们愿意,还要检查文档或代码以确保哪个是哪个。
我会有一个函数来接受参数, 另一个被称为“createVectorAndFoo”或类似的名称(显然,使用真正的问题时命名变得更容易)。
虽然这违反了“函数的两个职责”规则(并给它起了一个长名字),但我认为当您的函数确实执行两个操作(创建向量和foo它)时,这是更可取的。

我认为,如果函数确实执行了两个任务,那么这是一个设计缺陷;一扇破窗户(参见《实用程序员》),应该予以修复。 - John Dibling

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接