如何在python中进行驼峰式拆分

76

我想要实现的是这样的效果:

>>> camel_case_split("CamelCaseXYZ")
['Camel', 'Case', 'XYZ']
>>> camel_case_split("XYZCamelCase")
['XYZ', 'Camel', 'Case']

所以我搜索并找到了这个完美的正则表达式

(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])

作为下一步合乎逻辑的尝试:

>>> re.split("(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])", "CamelCaseXYZ")
['CamelCaseXYZ']

为什么这个不起作用,我该如何在Python中实现链接问题中的结果?

编辑:解决方案总结

我使用了几个测试用例来测试所有提供的解决方案:

string:                 ''
AplusKminus:            ['']
casimir_et_hippolyte:   []
two_hundred_success:    []
kalefranz:              string index out of range # with modification: either [] or ['']

string:                 ' '
AplusKminus:            [' ']
casimir_et_hippolyte:   []
two_hundred_success:    [' ']
kalefranz:              [' ']

string:                 'lower'
all algorithms:         ['lower']

string:                 'UPPER'
all algorithms:         ['UPPER']

string:                 'Initial'
all algorithms:         ['Initial']

string:                 'dromedaryCase'
AplusKminus:            ['dromedary', 'Case']
casimir_et_hippolyte:   ['dromedary', 'Case']
two_hundred_success:    ['dromedary', 'Case']
kalefranz:              ['Dromedary', 'Case'] # with modification: ['dromedary', 'Case']

string:                 'CamelCase'
all algorithms:         ['Camel', 'Case']

string:                 'ABCWordDEF'
AplusKminus:            ['ABC', 'Word', 'DEF']
casimir_et_hippolyte:   ['ABC', 'Word', 'DEF']
two_hundred_success:    ['ABC', 'Word', 'DEF']
kalefranz:              ['ABCWord', 'DEF']

总之可以说,@kalefranz的解决方案与问题不符(请参见最后一个案例),而@casimir和hippolyte的解决方案会吃掉一个空格,从而违反了“分割不应更改各个部分”的想法。在其余两个选择中唯一的区别是我的解决方案在空字符串输入时返回一个带有空字符串的列表,而@200_success的解决方案返回一个空列表。我不知道Python社区对这个问题的立场,所以我说:我对任何一种都没有意见。由于@200_success的解决方案更简单,所以我接受了它作为正确答案。


它怎么是ABC驼峰式命名法?! - mihai
1
@Mihai 我不明白你的问题。如果你想知道正则表达式在 "ABCCamelCase" 上的执行情况,那么它的结果是符合预期的:['ABC','Camel','Case']。如果你将 ABC 解释为 AbstractBaseClass 的缩写,那么我很抱歉造成了混淆,因为在我的问题中,ABC 只是三个任意的大写字母。 - AplusKminus
阅读我对类似问题的回答 - Matthias
1
这也是一个好答案,但由于措辞过于具体,我没有找到问题。此外,您的回答并没有完全满足此处所要求的内容,因为它生成了一个带有任意分隔符的转换字符串,您需要使用 str.split(' ') 将其拆分成(更通用的)各个部分的列表。 - AplusKminus
看一下链接的问题。我包含了大写部分,以满足将“someHTMLFile”这样的内容拆分为['some','HTML','File']的普遍愿望。 - AplusKminus
显示剩余2条评论
16个回答

65

正如 @AplusKminus 解释的那样,re.split() 永远不会在匹配到一个空模式时分割字符串。因此,你应该尝试查找你感兴趣的组件而不是分割字符串。

这里有一个使用 re.finditer() 模拟分割的解决方案:

def camel_case_split(identifier):
    matches = finditer('.+?(?:(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])|$)', identifier)
    return [m.group(0) for m in matches]

根据我的测试用例,我发现你的解决方案和我的有一个区别:在你的情况下,camel_case_split("")返回[],而在我的情况下返回[""]。问题是,你更倾向于哪一个被认为是预期结果。由于两者都适用于我的应用程序,所以我认为这是一个有效的答案! - AplusKminus
另一个问题仍然存在,那就是这个解决方案是否比我的提议的更好。我不是正则表达式复杂性方面的专家,所以这需要由其他人进行评估。 - AplusKminus
我们的正则表达式基本相同,只是我的以 .+? 开头捕获文本而不是丢弃它,并以 $ 结尾使其一直到末尾。任何更改都不会改变搜索策略。 - 200_success
1
不支持数字。例如,"L2S" 不会被分割成 ["L2", "S"]。在上述正则表达式中使用 [a-z0-9] 而不是 [a-z] 来解决这个问题。 - Neapolitan
1
@200_success,解析1解析2是我的分析,我并没有真正理解正则表达式。你能在这里帮忙吗? - Ravi Yadav
显示剩余3条评论

53

使用re.sub()split()

import re

name = 'CamelCaseTest123'
splitted = re.sub('([A-Z][a-z]+)', r' \1', re.sub('([A-Z]+)', r' \1', name)).split()

结果

'CamelCaseTest123' -> ['Camel', 'Case', 'Test123']
'CamelCaseXYZ' -> ['Camel', 'Case', 'XYZ']
'XYZCamelCase' -> ['XYZ', 'Camel', 'Case']
'XYZ' -> ['XYZ']
'IPAddress' -> ['IP', 'Address']

3
到目前为止,我认为最佳答案既优雅又有效,应该被选为答案。 - Pierrick Bruneau
3
很好,即使只是 re.sub('([A-Z]+)', r' \1', name).split() 对于简单情况也可以工作,当你没有像 'XYZCamelCase''IPAddress' 这样的输入时(或者如果你可以接受得到 ['XYZCamel', 'Case']['IPAddress']),其他的 re.sub 也考虑了这些情况(使每个小写字母序列仅附加到一个前面的大写字母)。 - ShreevatsaR
@PierrickBruneau,虽然我认为这个答案很优雅而且有效,但是我觉得它在通用Q&A网站礼仪方面缺乏一个重要方面:它没有回答问题。好吧,至少不完全回答,因为没有解释为什么问题的尝试不起作用。 - AplusKminus
@AplusKminus,我正在回答通过谷歌搜索“Python驼峰式拆分”并到达此处的新访问者。在我看来,他们正在寻找一般可复制粘贴的代码片段,并且没有遇到您的特定问题(因为他们是从头开始)。因此,不需要这样的解释。这就是为什么我所有“晚”回答都是这样的原因。我故意这样做。如果我在2015年回答并将这个答案针对您,那么您将会看到这样的解释。 - Jossef Harush Kadouri

13

当您不需要检查字符串的格式时,大多数情况下全局搜索比分割更简单(对于相同的结果):

re.findall(r'[A-Z](?:[a-z]+|[A-Z]*(?=[A-Z]|$))', 'CamelCaseXYZ')

返回值

['Camel', 'Case', 'XYZ']

为了处理驼峰式命名,您可以使用以下方法:

re.findall(r'[A-Z]?[a-z]+|[A-Z]+(?=[A-Z]|$)', 'camelCaseXYZ')
注意: (?=[A-Z]|$) 可以使用双重否定(具有否定字符类的负向前瞻)进行缩短:(?![^A-Z])

@SheridanVespo:这只适用于驼峰命名法,不适用于单峰命名法(如所要求)。但是可以通过少量更改以相同的方式完成。 - Casimir et Hippolyte
@SheridanVespo:是的,“dromedary-case”并不存在,但由于单峰驼只有一个驼峰,而双峰骆驼有两个……关于效率:它不仅仅是模式本身,而是你可以避免所有代码,因为你直接获得了你想要的字符串列表。关于正则表达式中的环视:环视并不是来自地狱,也不会很慢(只有在使用不当时才会减缓模式)。正如我刚刚告诉其他SO用户的那样,有些情况下,你可以通过环视来优化模式。 - Casimir et Hippolyte
测量了所有发布的解决方案。你和mnesarco的解决方案通过了所有Setop的测试,并且被证明是最快的。 - Ledorub

12

无需正则表达式的解决方案

我不太擅长使用正则表达式。我喜欢在我的IDE中使用它们进行搜索/替换,但我尽量避免在程序中使用它们。

这里是一个非常直接的纯Python解决方案:

def camel_case_split(s):
    idx = list(map(str.isupper, s))
    # mark change of case
    l = [0]
    for (i, (x, y)) in enumerate(zip(idx, idx[1:])):
        if x and not y:  # "Ul"
            l.append(i)
        elif not x and y:  # "lU"
            l.append(i+1)
    l.append(len(s))
    # for "lUl", index of "U" will pop twice, have to filter that
    return [s[x:y] for x, y in zip(l, l[1:]) if x < y]






And some tests

TESTS = [
    ("XYZCamelCase", ['XYZ', 'Camel', 'Case']),
    ("CamelCaseXYZ", ['Camel', 'Case', 'XYZ']),
    ("CamelCaseXYZa", ['Camel', 'Case', 'XY', 'Za']),
    ("XYZCamelCaseXYZ", ['XYZ', 'Camel', 'Case', 'XYZ']),
    ("aCamelCaseWordT", ['a', 'Camel', 'Case', 'Word', 'T']),
    ("CamelCaseWordT", ['Camel', 'Case', 'Word', 'T']),
    ("CamelCaseWordTa", ['Camel', 'Case', 'Word', 'Ta']),
    ("aCamelCaseWordTa", ['a', 'Camel', 'Case', 'Word', 'Ta']),
    ("Ta", ['Ta']),
    ("aT", ['a', 'T']),
    ("a", ['a']),
    ("T", ['T']),
    ("", []),
]

def test():
    for (q,a) in TESTS:
        assert camel_case_split(q) == a

if __name__ == "__main__":
    test()

编辑:一种可以在一次传递中流式处理数据的解决方案

这个解决方案利用了一个事实,即判断是否要分割单词可以在本地进行,只需考虑当前字符和前一个字符即可。

def camel_case_split(s):
    u = True  # case of previous char
    w = b = ''  # current word, buffer for last uppercase letter
    for c in s:
        o = c.isupper()
        if u and o:
            w += b
            b = c
        elif u and not o:
            if len(w)>0:
                yield w
            w = b + c
            b = ''
        elif not u and o:
            yield w
            w = ''
            b = c
        else:  # not u and not o:
            w += c
        u = o
    if len(w)>0 or len(b)>0:  # flush
        yield w + b

理论上,它速度更快,使用的内存更少。

同样的测试套件适用

但是列表必须由调用者构建。

def test():
    for (q,a) in TESTS:
        r = list(camel_case_split(q))
        print(q,a,r)
        assert r == a

在线尝试


2
谢谢,这是可读的,它能工作,并且有测试!在我看来比正则表达式的解决方案好多了。 - antimirov
提醒一下,这个程序在 World_Wide_Web => ['World_', 'Wide_', 'Web'] 这里会出错。同样的,它也会在 ISO100 => ['IS', 'O100'] 这里出错。 - stwhite
@stwhite,这些输入在原问题中并未被考虑。如果将下划线和数字视为小写,则输出是正确的。因此,这不会出错,它只是做它该做的事情。其他解决方案可能具有不同的行为,但再次强调,这不是初始问题的一部分。 - Setop

6
import re

re.split('(?<=[a-z])(?=[A-Z])', 'camelCamelCAMEL')
# ['camel', 'Camel', 'CAMEL'] <-- result

# '(?<=[a-z])'         --> means preceding lowercase char (group A)
# '(?=[A-Z])'          --> means following UPPERCASE char (group B)
# '(group A)(group B)' --> 'aA' or 'aB' or 'bA' and so on

为什么不直接使用 re.split('(?<=[a-z])(?=[A-Z])', 'camelCamelCAMEL') 呢? - Krzysztof Krzeszewski

6

我刚刚偶然发现了这个案例,并编写了一个正则表达式来解决它。实际上,它适用于任何一组单词。

RE_WORDS = re.compile(r'''
    # Find words in a string. Order matters!
    [A-Z]+(?=[A-Z][a-z]) |  # All upper case before a capitalized word
    [A-Z]?[a-z]+ |  # Capitalized words / all lower case
    [A-Z]+ |  # All upper case
    \d+  # Numbers
''', re.VERBOSE)

关键在于第一个可能情况的先行断言。它将匹配(并保留)大写单词和首字母大写的单词:
assert RE_WORDS.findall('FOOBar') == ['FOO', 'Bar']

1
我喜欢这个,因为它更清晰,并且对于像URLFinderlistURLReader这样的“人们在现实生活中输入的字符串”做得更好。 - Tom Swirly

3

Python的re.split文档指出:

请注意,split永远不会在空模式匹配上拆分字符串。

当看到这个时:

>>> re.findall("(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])", "CamelCaseXYZ")
['', '']

很明显,为什么分割不像预期的那样工作。正则表达式模块re找到了空匹配,就像正则表达式所期望的一样。

由于文档说明这不是一个错误,而是预期的行为,因此在尝试创建驼峰拆分时必须解决这个问题:

def camel_case_split(identifier):
    matches = finditer('(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])', identifier)
    split_string = []
    # index of beginning of slice
    previous = 0
    for match in matches:
        # get slice
        split_string.append(identifier[previous:match.start()])
        # advance index
        previous = match.start()
    # get remaining string
    split_string.append(identifier[previous:])
    return split_string

3

此解决方案还支持数字、空格和自动删除下划线:

def camel_terms(value):
    return re.findall('[A-Z][a-z]+|[0-9A-Z]+(?=[A-Z][a-z])|[0-9A-Z]{2,}|[a-z0-9]{2,}|[a-zA-Z0-9]', value)

一些测试:

tests = [
    "XYZCamelCase",
    "CamelCaseXYZ",
    "Camel_CaseXYZ",
    "3DCamelCase",
    "Camel5Case",
    "Camel5Case5D",
    "Camel Case XYZ"
]

for test in tests:
    print(test, "=>", camel_terms(test))

结果:

XYZCamelCase => ['XYZ', 'Camel', 'Case']
CamelCaseXYZ => ['Camel', 'Case', 'XYZ']
Camel_CaseXYZ => ['Camel', 'Case', 'XYZ']
3DCamelCase => ['3D', 'Camel', 'Case']
Camel5Case => ['Camel', '5', 'Case']
Camel5Case5D => ['Camel', '5', 'Case', '5D']
Camel Case XYZ => ['Camel', 'Case', 'XYZ']

这个正则表达式是否利用了第一个匹配条件会停止处理器查看其他条件的事实?否则我就不理解 [a-z0-9]{2,} 或者 [a-zA-Z0-9] 是什么意思。 - AplusKminus
这是因为在我的使用场景中,我需要支持“3D”,但如果输入已经用空格或下划线分隔,则还需要支持“3 D”。这个解决方案来自于我的自身需求,它比原始问题有更多的情况。是的,我利用了第一个匹配的事实。 - mnesarco

2

简单的解决方案:

re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", str(text))

这样做会在部分之间创建空格,然而问题要求创建一个部分数组。 - AplusKminus

1
这里有另一种解决方案,需要的代码更少,没有复杂的正则表达式:
def camel_case_split(string):
    bldrs = [[string[0].upper()]]
    for c in string[1:]:
        if bldrs[-1][-1].islower() and c.isupper():
            bldrs.append([c])
        else:
            bldrs[-1].append(c)
    return [''.join(bldr) for bldr in bldrs]

编辑

上述代码包含一种优化,避免了每个附加字符都重新构建整个字符串的情况。如果省略该优化,则一个更简单的版本(带有注释)可能如下所示

def camel_case_split2(string):
    # set the logic for creating a "break"
    def is_transition(c1, c2):
      return c1.islower() and c2.isupper()

    # start the builder list with the first character
    # enforce upper case
    bldr = [string[0].upper()]
    for c in string[1:]:
        # get the last character in the last element in the builder
        # note that strings can be addressed just like lists
        previous_character = bldr[-1][-1]
        if is_transition(previous_character, c):
            # start a new element in the list
            bldr.append(c)
        else:
            # append the character to the last string
            bldr[-1] += c
    return bldr

@SheridanVespo 我认为第一个版本可能有一个多余的 ),你帮我发现并纠正了 :) - kalefranz
@SheridanVespo 看起来“驼峰式大小写”有不同的定义。一些定义(也是我最初假设的)要求第一个字母大写。没关系,“错误”很容易修复。只需在初始化列表时删除.upper()调用即可。 - kalefranz
你能否创建一个版本,满足链接答案中的情况?此外,是否有一种方法可以比较您的方法和@Casimir et Hippolyte的方法的性能? - AplusKminus

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