在使用std::map时,有没有不使用异常来测试元素是否存在的理由?

4

我最近开始在一些项目中使用C++11,并且也开始大量使用stl容器,对于它们我还比较新手。

我最近编写了一个函数,其功能类似于:

CMyClass* CreateAndOrGetClass( int _iObjectId, std::map<int, CMyClass*>& _mapObjectData )
{
    CMyClass* pClassInstance{ nullptr };

    try
    {
        pClassInstance = _mapObjectData.at( _iObjectId);
    }
    catch ( ... )
    {
        pClassInstance = new CMyClass();
        __mapObjectData.insert( _iObjectId, pClassInstance );
    }

    return ( pClassInstance );
}

我的问题是关于使用异常处理非“异常”情况。这似乎是实现目的的简洁方法,而不需要涉及迭代器的设置。
在使用异常处理此类目的时,是否存在任何需要注意的问题我可能会忽略?
* 测试结果:
为了进一步说明,我进行了一些性能测试,将基于异常处理的代码与所选答案中的代码示例进行了比较。 这是一个很好的练习,有助于证明使用异常处理普通代码流程的性能建议的不良性能,并提供了一些有关未抛出异常时几乎没有性能损失的断言的参考材料。
虽然这些测试并不全面,但以下是我的观察结果:
每个测试在使用rand()作为map键源(生成最大32768个元素)的大量插入/获取操作上运行:
异常方法:平均5.99秒 查找/添加方法:平均0.75秒
将随机元素的范围扩大十倍,得到以下数字:
异常方法:平均56.7秒 查找/添加方法:平均4.54秒
然后,我预填充地图以包含所有可能的键条目,以便try永远不会抛出:
异常方法:平均0.162秒 查找/添加方法:平均0.158秒
有趣的是,在MS VStudio中,异常处理方法的调试模式代码速度更快。

3
你的代码将会变得更慢,难以调试,而且阅读起来肯定更加晦涩难懂。 - Cameron
5个回答

4
编译器通常使用一种实现异常的策略,只要没有抛出异常就不会有运行时开销,但如果抛出异常,则由于异常处理机制必须展开堆栈,会影响程序性能。
即使对于您的用例来说这是可以接受的,但在您的情况下使用异常来管理控制流没有任何优势。使用 map::find 不仅更简洁,而且更符合惯用法。
auto iter = _mapObjectData.find(_iObjectId);
if(iter == _mapObjectData.end()) {
  auto instance = new CMyClass();
  _mapObjectData.insert(std::make_pair(_iObjectId, instance));
  return instance;
}
return iter->second;

@Mehrdad在评论中提出了一个好的建议,使用map::lower_bound来定位键而不是map::find。好处是,如果键不存在,则返回值可以用作提示用于map::insert,这应该可以提高插入性能。

auto iter = _mapObjectData.lower_bound(_iObjectId);
if(iter == _mapObjectData.end() || iter->first != _iObjectId) {
  auto instance = new CMyClass();
  _mapObjectData.insert(iter, std::make_pair(_iObjectId, instance));
  return instance;
}
return iter->second;

我强烈建议您将地图类型更改为:
std::map<int, CMyClass*>

std::map<int, std::unique_ptr<CMyClass>>

将拥有资源的原始指针放入标准库容器中通常会带来更多麻烦而不是价值。

@T.C. 谢谢。发布未编译的代码的风险 :) - Praetorian
谢谢。有趣的是,这段代码看起来与我刚写的非异常版本完全相同,因为我想测量是否有任何差异。但我的更大担忧实际上是你所说的,以及我使用异常来控制流程而不是它们所声明的原因的一部分。 - jgibbs
@Praetorian:你应该使用lower_bound而不是find(但别忘了再次检查键是否正确)。此外,我认为_mapObjectData.insert(_iObjectId, instance)根本不起作用 :-) 你应该传递一个pair(可能还包括一个迭代器,以避免进行第二次查找,希望这是从lower_bound得到的)。 - user541686
1
如果insert抛出异常,会导致内存泄漏。 - Cheers and hth. - Alf
@Mehrdad 感谢您发现 map::insert 错误,我没有多想就复制了 OP 的代码。我已经在答案中加入了 lower_bound 建议。 - Praetorian
当然会。这就是为什么我在最后建议使用unique_ptr的原因。@Cheersandhth.-Alf - Praetorian

4
异常是一种将故障处理与正常情况代码清晰分离的方法。
在您的代码中,它们用于正常情况,这违背了其目的并没有优势。
在手头的特定情况下,请使用[]索引,如果键不存在,则会自动插入该键。更一般地说,对于简单的条件控制流,请使用条件构造。有一些例外情况,其中异常用于表达正常情况控制流(例如从深度嵌套的递归调用返回结果),在这种情况下,代码可以变得更简单和更清晰,但这些例外情况是......例外。

关于效率,抛出异常是很耗费资源的,因为C++编译器仅优化了异常用于处理失败的情况,而这种情况被认为是罕见的。

当失败变成常态时,应重新考虑失败的工作定义。

然而,仅有可能抛出异常的情况下,开销非常小,甚至可以忽略不计。因此,您不必担心使用异常来安全地处理故障,将其与正常情况代码分开处理。如果使用得当,通过将代码分为正常情况和故障情况,异常就是双赢的。


另一个答案的评论中,您提到:

虽然我喜欢这种简洁性,但如果无法实例化类,则可能会使地图留下一个nullptr。

嗯,是一种失败的情况,使用异常处理是正确的方法。

例如,

Your_class* creative_at( int const id, std::map<int, YourClass*>& object_data )
{
    // Basic non-creating at:
    {
        auto const it = object_data.find( id );
        if( it != object_data.end() ) { return it->second; }
    }

    // Create:
    std::unique_ptr<Your_class> p( new Your_class() );    // May throw.
    object_data[id] = p.get();                            // May throw.
    return p.release();
}

这段代码基于异常处理,但是看不到任何 try-catch 语句。
与 Java 的手动 try-catch-finally 不同,在 C++ 中,主要是通过析构函数进行自动清理,例如在此处使用的 std::unique_ptr 析构函数;这种方法被称为 RAII,即资源获取即初始化

这是一个很好的例子。我喜欢使用 unique_ptr 来控制创建和赋值过程中可能出现的异常。 - jgibbs
就此而言,我相当熟悉C++并已使用它相当长的时间,并最近阅读了Bjarne的第四版书籍,以便跟上最新技术。正是从那本书中,我真正地采纳了常量推荐的建议,这个主题绝不能没有。事实证明,我最初发布的异常方法在我的系统上(在Win7上,32位发布版本)比这种方法慢大约80倍。 - jgibbs

3

在C++中避免使用过多的异常有原因:异常处理很耗费资源(因为必须执行所有中间调用帧中本地变量的析构函数)。

在Ocaml中,异常处理是轻量级的,所以更可能像你建议的那样使用。

在您的示例中,我更愿意使用std::mapfind成员函数。


1
谢谢您的回复,但我真正需要的是在这种情况下的建议,即异常包含在测试范围内。通常,由于语言在抛出异常时缺乏捕获它们的强制执行,我一直在避免使用异常。 - jgibbs

2

是的,因为既要传播异常,又要执行代码本身都不是特别高效。

忽略不应该在第一位存储原始指针的事实,做你正在做的事情的下一个最正确,最高效(而且易读!)的方法是使用operator[]:

CMyClass* CreateAndOrGetClass(int _iObjectId, std::map<int, CMyClass*>& _mapObjectData)
{
    CMyClass* &pClassInstance = _mapObjectData[_iObjectId];
    if (!pClassInstance) { pClassInstance = new CMyClass(); }
    return pClassInstance;
}

这样,查找映射表只会进行一次,同时值也是原地构造的。
当然,在实际情况中,您可能应该将CMyClass存储为值,而不是CMyClass *,但这与我在此处回答的重点无关。

2
这将为地图中已经存在的空指针执行new操作(尽管不清楚OP的代码是否有意这样做)。 - M.M
@MattMcNabb:很好的发现,但我不认为OP打算将null作为映射中的有效值。 - user541686
@jgibbs:是否还有其他过程也在访问该地图?因为在我看来,这个过程似乎是唯一访问该地图的过程,在这种情况下,您不需要担心空值是否存在,因为它不会对任何事情产生不利影响。 - user541686
@jgibbs:此外,我甚至不理解你的担忧:如果你担心类没有正确实例化,那么这意味着你希望你的代码是异常安全的(异常是代码在中途中止并在映射中留下悬空空指针的唯一方式)。但是,如果你担心会抛出异常,那么你的代码已经有了更大的问题(裸指针,你似乎已经意识到了),而你完全忽视了它。与你忽视的泄漏相比,映射中的悬空空指针将是你最不用担心的问题。 - user541686
悬空的空值更多的是一个侧面问题,基于以下事实:如果一个类故意缺少数据而失败,那么 'new' 的失败可能是无法恢复的,甚至是可预期的。这种方法向映射中添加一个元素,如果需要迭代映射,则需要稍后检查该元素。在我的情况下,是的,其他进程正在访问该映射,否则我不会将此数据放入其中。只是碰巧在使用此函数的地方,调用者需要立即使用该项,因此在这种情况下将获取/创建功能组合起来是有意义的。 - jgibbs
显示剩余2条评论

0

正如其他人所指出的那样,异常在抛出/捕获时会变慢。

但这并不意味着替代方案必须丑陋。

if(!mymap.count(objectId))
{

}

告诉您对象是否缺失。

此外,请确保您是否需要指针来存放该映射表的值。这样做的唯一原因是如果您的类的复制构造函数非常慢,或者该类不可复制/移动。即使在这种情况下,您也应该使用unique-ptrshared-ptr

此外,您的匈牙利命名法版本,例如C、下划线i、p等前缀越来越不流行。许多人认为它带来的麻烦大于帮助。请参见Alexandescu和Sutter的C++编码标准,第0章。


我只对探索异常处理的开销和问题真正感兴趣。我理解使用原始指针的理由和反对意见,如果我在我的示例中使用它们,这不会改变我寻找答案的问题,但还是感谢您的建议。匈牙利命名法方法的一些工件仍然非常有用,其中一些我们选择使用以使所有开发人员受益。我个人从未遇到过使用这些有用部分的麻烦。 - jgibbs

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