有没有一个简洁的与“空”的相反的词?

23

通常字符串类的接口会有一个名为IsEmptyVCL)或者emptySTL)的方法。这是很合理的特例,但使用这些方法的代码经常需要否定这个谓词,从而导致"视觉(甚至心理上的)负担"(感叹号在开括号后面不太明显)。例如,看看这个(简化的)代码:

/// format an optional time specification for output
std::string fmtTime(const std::string& start, const std::string& end)
{
    std::string time;
    if (!start.empty() || !end.empty()) {
        if (!start.empty() && !end.empty()) {
            time = "from "+start+" to "+end;
        } else {
            if (end.empty()) {
                time = "since "+start;
            } else {
                time = "until "+end;
            }
        }
    }
    return time;
}

由于空情况需要跳过,因此它有四个否定。我经常观察到这种否定,甚至在设计界面时也是如此,虽然这不是一个大问题,但很烦人。我只希望支持编写易于理解和易于阅读的代码。我希望你能理解我的观点。

也许我只是被蒙住了双眼:你会如何解决上述问题?


编辑:在阅读了一些评论后,我认为有必要说明一下,原始代码使用了VCL的System::AnsiString类。这个类提供了一个非常易读的IsEmpty方法:

 if (text.IsEmpty()) { /* ... */ } // read: if text is empty ...

如果未被否定:

 if (!text.IsEmpty()) { /* ... */} // read: if not text is empty ... 

...而不是如果文本不为空。我认为字面上的is最好让读者自己想象,这样否定形式也可以很好地工作。好吧,也许不是一个普遍的问题...


17
你需要多简洁,比一个额外的 ! 字符更简洁吗?我建议使用本地变量来简化阅读否定表达式。bool hasStart = !start.empty(); 然后逻辑就更容易阅读了:if (hasStart || hasEnd) { ... - David Rodríguez - dribeas
或者您可以将所有的 if 重构为正向的 empty 测试,仍然可以消除最外层的 if - Potatoswatter
4
我只希望支持编写易于理解和易于阅读的代码。如果人们阅读你的代码时无法理解!foo.empty(),那么你就有了一个更大的问题。请看周围,这被广泛使用,每个人都很好地理解它。 - PlasmaHH
2
@PlasmaHH 这是真的,但是not显然更难被忽视... - Wolf
5
@Wolf: 不是的。我讨厌人们在C++代码中滥用额外的andnotor运算符,而不是使用同样易懂且更显眼的运算符&&||!。可以这样理解:当人们使用语言形式时,整个布尔代码会变得不那么有结构性,因为只有单词没有标点符号,然后not就不那么突出了。就像没有任何标点符号的长句子对于许多人来说阅读起来很困难,在世界上和可能存在的太空生物也是如此,这可能与标点符号的历史有关... - Sebastian Mach
从纯逻辑层面上看,您可以用 !(start.empty() && end.empty()) 替换 !start.empty() || !end.empty() 以及用 !(start.empty() && end.empty()) 替换 !start.empty() && !end.empty() - Simon Kuang
14个回答

29

在大多数情况下,您可以颠倒ifelse的顺序来简化代码:

const std::string fmtTime(const std::string& start, const std::string& end)
{
    std::string time;
    if (start.empty() && end.empty()) {
        return time;
    }

    if (start.empty() || end.empty()) {
        if (end.empty()) {
            time = "since "+start;
        } else {
            time = "until "+end;
        }
    } else {
        time = "from "+start+" to "+end;
    }
    return time;
}

经过更多的重构后,甚至可以更加简洁:

std::string fmtTime(const std::string& start, const std::string& end)
{
    if (start.empty() && end.empty()) {
        return std::string();
    }

    if (start.empty()) {
        return "until "+end;
    }    

    if (end.empty()) {
        return "since "+start;
    }

    return "from "+start+" to "+end;
}

对于更紧凑的版本(尽管我更喜欢之前的版本,因为它更易读):

std::string fmtTime(const std::string& start, const std::string& end)
{
    return start.empty() && end.empty() ? std::string()
         : start.empty()                ? "until "+end
         :                  end.empty() ? "since "+start
                                        : "from "+start+" to "+end;
}

另一种可能性是创建一个辅助函数:

inline bool non_empty(const std::string &str) {
  return !str.empty();
}

if (non_empty(start) || non_empty(end)) {
...
}

辅助函数的方法似乎相当有趣 - 现在我正在阅读Scott Meyers Item #23, Third Edition - Wolf
6
@JamesKanze 可读性是一件很个人化的事情。我更喜欢阅读作用域层次较少的代码。早期返回也有助于澄清特殊情况和条件。 - C. E. Gesser
嗯,我甚至几乎考虑将最后一个示例中的4个if/return转换为带有条件运算符的单个return。如果格式正确,它甚至可以既易读又简洁。 - Cruncher
@Cruncher 嵌套条件运算符可能会导致括号炎 ;) - Wolf
顺便说一下,在现代IDE(如NetBeans)中进行这种逻辑重构非常容易。 - Simon Kuang
你可以将non_empty模板化,以使其适用于其他容器。 - Mohit Jain

12

我认为我会取消条件,转而使用一些数学:

const std::string fmtTime(const std::string& start, const std::string& end) {

    typedef std::string const &s;

    static const std::function<std::string(s, s)> f[] = {
       [](s a, s b) { return "from " + a + " to " + b; }           
       [](s a, s b) { return "since " + a; },
       [](s a, s b) { return "until " + b; },
       [](s a, s b) { return ""; },
    };

   return f[start.empty() * 2 + end.empty()](start, end);
}

编辑:如果您喜欢,您可以将数学公式表达为start.empty() * 2 + end.empty()。为了理解正在发生的事情,也许最好是我先对起始时如何思考事物进行阐述。我把它们想象成一个二维数组:

enter image description here

(根据您更喜欢按行主序还是列主序思考,可以随意交换“开始为空”和“结束为空”)。

start.empty()end.empty()(或者它们的逻辑非,如果您更喜欢)各自在该 2D 矩阵的一个维度上充当索引。所涉及的数学仅仅是“线性化”了这个寻址过程,因此我们不再有两行两列,而是得到了一长行,类似于这样:

enter image description here

在数学术语中,这只是简单地“行 * 列 + 列”(或者反之,具体取决于您是更喜欢按行主序还是列主序排序)。我最初将 * 2 部分表示为位移,并将加法表示为位 or(知道最低有效位是空的,因为前面进行了左移)。我觉得这很容易处理,但我可以理解其他人可能会有不同看法。

我应该补充一点:虽然我已经提到行主序和列主序,但显然从两个“x.empty”值到数组中位置的映射基本上是任意的。我们从 .empty() 获得的值意味着当该值不存在时我们获得 0,当存在时我们获得 1。因此,从原始值到数组位置的直接映射可能像这样:

enter image description here

由于我们正在线性化该值,我们可以选择如何进行映射:

  1. 简单地安排数组来适应我们得到的值。
  2. 分别为每个维度反转该值(这基本上就是导致原始问题的原因——频繁使用!x.empty()
  3. 将两个输入组合成一个单独的线性地址,然后通过从 3 中减去“反转”。

对于那些怀疑其效率的人,它实际上编译成以下内容(使用 VC++):

mov eax, ebx
cmp QWORD PTR [rsi+16], rax
sete    al
cmp QWORD PTR [rdi+16], 0
sete    bl
lea eax, DWORD PTR [rbx+rax*2]
movsxd  rcx, eax
shl rcx, 5
add rcx, r14
mov r9, rdi
mov r8, rsi
mov rdx, rbp
call    <ridiculously long name>::operator()

即使只是为了f进行一次构建,也远没有某些人想象的那么糟糕。它不涉及动态分配或任何类似的事情。名称足够长,在最初看起来有点可怕,但最终它主要是四个重复:

lea rax, OFFSET FLAT:??_7?$_Func_impl@U?$_Callable_obj@V<lambda_f466b26476f0b59760fb8bb0cc43dfaf>@@$0A@@std@@V?$allocator@V?$_Func_class@V?$basic_string@DU?$char_traits@D@std@@V?$allocator@D@2@@std@@AEBV12@AEBV12@@std@@@2@V?$basic_string@DU?$char_traits@D@std@@V?$allocator@D@2@@2@AEBV42@AEBV42@@std@@6B@
mov QWORD PTR f$[rsp], rax

省略 static const 看起来并不会对执行速度产生太大影响。由于该表是静态的,我认为它应该在那里,但就执行速度而言,如果表初始化涉及四个单独的动态分配或任何类似的操作,它并不能带来我们所期望的大量优势。


1
@Wolf 我认为这并不是“太聪明了”——这种方法不难阅读,并且可以很容易地移植到使用switch语句的C++11之前的编译器中。 - Sergey Kalinichenko
9
太难懂了吗?我知道很多C++程序员在理解这段代码之前需要求助。就连我自己也得看两遍,虽然我做过很多底层的C++,所以我熟悉位操作和bool类型转换为int类型的隐式转换(我本人会尽量避免这种情况)。 - James Kanze
1
@C.E.Gesser:你是基于什么来得出这个代码是低效的结论的?你有实际测试过吗?我必须承认,我对这个代码难以理解的说法感到有些困惑。你觉得哪一部分难懂,是乘以2还是按位或运算符? - Jerry Coffin
@JerryCoffin 对我来说还好,但是一个经验较少的程序员可能会遇到困难。对我来说性能问题在于 std::function,它可能会在内部使用堆分配(即使只有一次,因为它们被声明为 static)。而且我不太确定调用成本,编译器是否可以内联所有内容。当然,我可能是错的。理想情况下,我们应该对其进行分析并查看生成的汇编代码以确保。 - C. E. Gesser
@Wolf 这只会加强这种逻辑的晦涩难懂。 - C. E. Gesser
显示剩余2条评论

7

你可以这样说

if (theString.size()) { .... }

无论哪种方式更易读,这里你调用的方法主要目的不是告诉你是否为空,并且依赖于隐式转换为bool。我更喜欢使用!s.empty()版本。为了好玩,我可能会使用not代替:
if (not theString.empty()) { .... }

看到使用!not版本感到困惑的人之间的相关性可能很有趣。


2
@Wolf:我认为非运算符更明显。而且对于某些容器,size()的复杂度为O(n),而empty()的复杂度为O(1)。 - Sebastian Mach
1
@phresnel同意,而且size()是用来获取一个东西的大小的!但是你是否知道在C++11中有哪些容器的size()方法是O(n)的?我知道C++03的std::list - juanchopanza
@juanchopanza:哎呀,看起来标准已经改变了措辞,现在所有容器的size()都应该是O(const)。当然,语义问题并未改变。我正在查看容器是否为空(empty()! empty()),而不是它的大小。 - Sebastian Mach
4
那是非常不幸的改变,因为现在list::splice变成了O(N),只是为了计算元素数量并相应地更新列表大小 :( - Matthieu M.
@MatthieuM:也许一个较小的改变是完全删除list::size(),并使用list::count()代替。我不知道,我对那个话题不是很深入... - Sebastian Mach
显示剩余2条评论

5

出于强迫症的原因,我必须重构这个代码...

std::string fmtTime( const std::string & start, const std::string & end ) {
    if ( start.empty() ) {
        if ( end.empty() ) return ""; // should diagnose an error here?

        return "until " + end;
    }

    if ( end.empty() ) return "since " + start;

    return "from " + start + " to " + end;
}

尽量保持干净,如果有难以理解的地方,请添加注释而不是另一个if语句。


@MatthieuM。如果代码到处都有返回,那么你就无法对其进行逻辑推理。有些情况下,多个返回是合理的:例如每个分支都以返回结尾的 switch 或 if/else if 链。但类似上述情况是完全不可接受的,在我工作过的任何地方,这都会导致程序员被解雇。 - James Kanze
1
@JamesKanze:这很有趣,因为在我工作的地方,即使是一行代码的if语句,只要有系统性的{},这段代码也会成为良好实践的例子,而OP提出的嵌套if/else将永远无法通过代码审查。 - Matthieu M.
@MatthieuM。但是上面的代码也有嵌套的if/else(除非他们隐藏了else)。如果你把return看作是跳转到函数的结尾(这实际上就是它的作用),那么上面的代码就是真正的意大利面条式编程。 - James Kanze
6
除非你将return视为从函数的剩余部分中排除给定条件,否则它并不是这样。提前返回会捕获错误或缩小语义范围。特殊情况如“如果两者都为空”,不应该将嵌套添加到整个函数体中,并与其他所有内容一起使用! - Potatoswatter

4
通常最好不要使用如此复杂的条件代码。为什么不保持简单呢?

const std::string fmtTime(const std::string& start, const std::string& end)
{
    if (start.empty() && end.empty())
    {
        return "";
    }
// 这里至少有一个开始时间或结束时间不为空。
std::string time;
if (start.empty()) { time = "直到 "+end; } else if (end.empty()) { time = "从 "+start+" 开始"; } else // 两个都不为空 { time = "从 "+start+" 至 "+end; }
return time; }

return std::string(); 怎么样?但其他部分都准备好了。 - Wolf
我认为在这里是否返回 std::string() 或者只是 "" 并不重要。 - Konstantin Oznobihin
return std::string(); 可能更快,因为 return "" 会调用 std::string(const char*),需要检查其参数的长度。 - C. E. Gesser

3

不使用否定词.. ;)

(这是一句玩笑话,无需翻译)
const std::string fmtTime(const std::string& start, const std::string& end)
{
   std::string ret;
   if (start.empty() == end.empty())
   {
     ret = (start.empty()) ? "" : "from "+start+" to "+end;
   }
   else
   {
     ret = (start.empty()) ? "until "+end : "since "+start;
   }
   return ret;
}

编辑:好的,进一步整理了一下……


没错。但是它也更难获得。(我说的不是“难”,而是“更难”,并且它还有4个回车) - Wolf

3

由于没有人愿意在我的评论中输入完整的答案,所以我来回答:

创建本地变量以简化表达式的阅读:

std::string fmtTime(const std::string& start, const std::string& end)
{
    std::string time;
    const bool hasStart = !start.empty();
    const bool hasEnd   = !end.empty();
    if (hasStart || hasEnd) {
        if (hasStart && hasEnd) {
            time = "from "+start+" to "+end;
        } else {
            if (hasStart) {
                time = "since "+start;
            } else {
                time = "until "+end;
            }
        }
    }
    return time;
}

编译器足够聪明,可以省略这些变量,即使它没有这样做,它也不会比原来的效率低(我期望两者都是变量的单一测试)。现在的代码对于人类来说更易读,可以直接阅读条件:如果有起始或结束
当然,您也可以进行不同的重构,以进一步简化嵌套操作的数量,例如单独处理没有起始或结束的情况并尽早退出...

不错的想法。但如果嵌套是个问题,可以使用 if ( hasStart && hasEnd ) ... else if ( hasStart ) ... else if ( hasEnd )...。这样清晰明了,没有嵌套条件(至少对于人类读者而言)。 - James Kanze
@Wolf 我知道。 但是当然,您嵌套的越少,代码就越清晰(前提是您不会添加条件返回等内容,这比过度嵌套还糟糕)。 - James Kanze
@DavidRodríguez-dribeas 但是变量越少,混淆它们的机会就越少 ;) - Wolf
@Wolf:这就是命名成为问题的地方。如果变量被正确命名,就不应该有混淆。如果有混淆,那么名称选择得不好。 - David Rodríguez - dribeas
1
@DavidRodríguez-dribeas: 在我看来,如果一个函数的目的是返回无副作用的东西,那么直接返回计算结果比将它们存储到临时变量中更清晰,除非在某些情况下受益于能够在编写后更改临时变量(如果代码向临时变量中写入,然后稍后返回它,则必须检查写入和返回之间的代码,以查看返回值是否与最初写入的值相匹配)。如果函数将具有副作用,则所有的返回都应该在第一个返回或最后一个返回之前。 - supercat
显示剩余2条评论

3

全球范围内,我对你的写法没有问题;这肯定比其他人提出的替代方案更加简洁。如果你担心!会消失(这是一个合理的担忧),可以使用更多的空格。

if ( ! start.empty() || ! end.empty() ) ...

或者尝试使用关键词not

if ( not start.empty() || not end.empty() ) ...

(大多数编辑器都会将not突出显示为关键字,这将更加引人注目。)

否则,有两个辅助函数:

template <typename Container>
bool
isEmpty( Container const& container )
{
    return container.empty();
}

template <typename Container>
bool
isNotEmpty( Container const& container )
{
    return !container.empty();
}

这样做的额外好处是给功能命名更合适。 (函数名称应该是动词,所以c.empty()在逻辑上意味着“清空容器”,而不是“容器是否为空”。但是,如果您开始包装所有具有糟糕名称的标准库函数,则需要花费大量的工作。)

有趣的观点。不幸的是,我的格式化程序对空格有问题,在我的开发环境中不支持“not”。 - Wolf
抱歉,我疏忽了你的辅助函数,它们非常好 - 但我担心它们在我们公司不适用 ;) - Wolf
@C.E.Gesser 或许是这样,但这并不是全部的真相:阅读 if (container) 条件时只有一种正确的方法吗?这些模板似乎旨在全局工作。 - Wolf
你的方法减少了标记,否定(因此心理负担)保持不变。 - Wolf

2

我也遇到了负面逻辑的心理负担。

当无法避免时,解决方案之一是检查明确条件,例如:

if (!container.empty())

vs

if (container.empty() == false)

第二个版本更容易理解,因为它像你朗读的那样流畅。它也清楚地表明了你正在检查一个错误的条件。
现在如果这仍然不够好,请创建一个继承自您正在使用的任何容器的薄包装类,然后为该特定检查创建您自己的方法。
例如,对于字符串:
class MyString : public std::string
{
   public:
     bool NotEmpty(void)
     { 
       return (empty() == false); 
     }
};

现在它只是这样:
if (container.NotEmpty())...

1
根据我的经验,很少有软件人员能够真正欣赏清晰编写的源代码。问题也在于这是一种主观选择。我试图提出一个论点,即代码应该按照你阅读英语句子的方式来编写。有时候这会有所帮助,但是有许多人只是习惯于处理负面逻辑,无法被说服。 - Lother
@Lother:但是empty() == false不是一个非常英语化的短语。你不会说“桶子的空洞是假的”,而是说“桶子__是空的__”或者“桶子__不是空的__”,即bucket.empty()! bucket.empty() - Sebastian Mach
1
@Lother:必须说我还是不明白。我赞同负逻辑不好的观点(我总是尽力将其降至最低,但不能更少)。应该始终只有一个间接引用,如果你有更多,那么你就违反了洛德米特法则。而且,你现在通过一个间接引用或集合组合器operator ==将两个布尔表达式缩减为一个单一的表达式。 - Sebastian Mach
关于流程,容器为空对于正面情况来说是可以的。对于负面情况,!会带来问题。从左到右:不是容器为空。需要跳跃来放置限定词not,否则它不会在自然阅读短语的流程中出现。至于将两个表达式简化为一个,这在这里是一个红鲱鱼。!container.empty()已经是两个表达式了,这被一元运算符所混淆,这也是造成很多麻烦的原因。 :) - Lother
在英语语言中,container.empty() == false无法进行抽象化。实际上,Container.empty()是一个二进制状态的函数。测试其是否为false非常简单明了。 - Lother
显示剩余7条评论

2

如果你只关心!可能被忽略的难度,你可以使用标准的C++替代符号not

const std::string fmtTime(const std::string& start, const std::string& end)
{
    std::string time;
    if (not start.empty() or not end.empty()) {
        if (not start.empty() and not end.empty()) {
            time = "from "+start+" to "+end;
        } else {
            if (end.empty()) {
                time = "since "+start;
            } else {
                time = "until "+end;
            }
        }
    }
    return time;
}

(参见标准中的[lex.digraph]以获取替代标记)

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