3
\$\begingroup\$

I was looking into Python 3.10's Structural Pattern Matching syntax, and I want to refactor one of my code that uses if-else, using structural pattern matching. My code works, but I'm trying to find out if there's a better way to deal with a particular section.

Let's consider this simple example problem, let's say I have the following list:

data = [                    # index
    [1, 2, 5, 3, 4],        #   0
    [7, 5, 8, 4, 9],        #   1
    [2, 3, 4, 4, 5],        #   2
    [1, 3, 1, 6, 7],        #   3
    [5, 6, 0, 7, 8],        #   4
    [4, 3, 0, 7, 5],        #   5
    [4, 4, 4, 5, 4],        #   6
    [5, 2, 9, 3, 5],        #   7
]

What I want to do is:

IF: (there is a `4` *or* `5` at the *beginning*)
    prepend an `'l'`.

ELIF: (there is a `4` *or* `5` at the *end*)
    prepend a `'r'`.

ELIF: (there is a `4` *or* `5` at *both ends* of the list)
    IF:  (Both are equal)
        prepend a `'b2'`, 
    ELSE: 
        prepend `'b1'`

ELSE:
    IF : (There are at least **two** occurrences of `4` *and/or* `5`) 
        prepend `'t'`
    ELSE: 
        prepend `'x'`

Each inner_list may contain arbitrary amount of elements.

Expected Result

index   append_left
  0         'r'
  1         't'
  2         'r'
  3         'x'
  4         'l'
  5         'b1'
  6         'b2'
  7         'b2'

Now, I can do this using structural pattern matching, with the following code:

for i, inner_list in enumerate(data):
    match inner_list:

        case [(4 | 5) as left, *middle, (4 | 5) as right]:
            data[i].insert(0, ('b1', 'b2')[left == right])

        case [(4 | 5), *rest]:
            data[i].insert(0, 'l')

        case [*rest, (4 | 5)]:
            data[i].insert(0, 'r')

        case [_, *middle, _] if (middle.count(4) + middle.count(5)) >= 2:  ## This part ##
            data[i].insert(0, 't')
        
        case _:
            data[i].insert(0, 'x')

pprint(data)

Output

[['r', 1, 2, 5, 3, 4],
 ['t', 7, 5, 8, 4, 9],
 ['r', 2, 3, 4, 4, 5],
 ['x', 1, 3, 1, 6, 7],
 ['l', 5, 6, 0, 7, 8],
 ['b1', 4, 3, 0, 7, 5],
 ['b2', 4, 4, 4, 5, 4],
 ['b2', 5, 2, 9, 3, 5]]

The problem is the ## marked block above. Of course I can move that part inside the block, and check there, but I was wondering whether the if part can be avoided altogether, i.e. some pattern that would match at least two 4 and/or 5s.


EDIT The [_, *middle, _] part is actually me being suggestive that I am looking for a pattern to match the scenario, I'm aware, in actuality this could be done like: case _ if sum(i in (4, 5) for i in inner_list) >= 2

\$\endgroup\$
3
  • 1
    \$\begingroup\$ The reason why I mentioned the syntax [(4 | 5), *rest] is unfortunate is because it slices the list which can be an expensive operation. Now, for every case you are performing a seperate slice. [(4 | 5), *rest] slices, [_, *middle, _] slices etc. The syntax in itself is clear, but not efficient. \$\endgroup\$ – N3buchadnezzar Jun 7 at 18:44
  • \$\begingroup\$ Yes, I concur, I just added the edit for [_, *middle, _], which is unnecessary in every sense. At least the *rest part would be useful if I needed to use the rest (which I don't at the moment). \$\endgroup\$ – Cyttorak Jun 7 at 18:49
  • \$\begingroup\$ The implementation for [(4|5), *rest] isn't necessarily inefficient. There can only be one starred field, so the implementation could try to match the other parts of the pattern first. If the other parts succeed, the *rest matches whatever is left over--it always succeeds. So the actual slicing only has to occurs for a successful match. If you use *_ instead of *rest the slicing isn't needed at all. Patterns like this, that match the beginning and/or end of a sequence, would be rather common and would be a good optimization target for the interpreter developers. \$\endgroup\$ – RootTwo Jun 9 at 23:32
4
\$\begingroup\$

Like you, I am excited about Python's new pattern matching. Unfortunately, I don't think your use case is a good fit. Every tool has strengths and weaknesses. In this specific situation, ordinary if-else logic seems easier to write, read, and understand. For example:

def get_prefix(xs):
    lft = xs[0]
    rgt = xs[-1]
    T45 = (4, 5)
    if lft in T45 and rgt in T45:
        return 'b2' if lft == rgt else 'b1'
    elif lft in T45:
        return 'l'
    elif rgt in T45:
        return 'r'
    elif sum(x in T45 for x in xs) > 1:
        return 't'
    else:
        return 'x'

new_rows = [[get_prefix(row)] + row for row in data]
\$\endgroup\$
6
  • \$\begingroup\$ I kind of agree. And this is how my code was originally written, but thought I'd give the new thing a go, to see if I can use it to replace if else completely. btw isn't adding two lists O(n) as opposed to insert which is O(1)? \$\endgroup\$ – Cyttorak Jun 7 at 17:40
  • \$\begingroup\$ I am mistaken, insert is indeed O(n), I conflated with deque.appendleft \$\endgroup\$ – Cyttorak Jun 7 at 17:50
  • 1
    \$\begingroup\$ @Cyttorak Sadly, I mostly ignored your specific question (ie, is it possible). I don't know for certain. But if it's possible, it's likely to be even more elaborate than the current code you have. Regarding insert, your second comment is right. That said, if you care about speed, measure it for your specific case. These things can have counter-intuitive results, because some Python operations occur at C-speed, others at Python-speed. I wrote my example code that way mostly out of habit: functions should not mutate without good reason. But your use case might want to mutate. \$\endgroup\$ – FMc Jun 7 at 17:56
  • \$\begingroup\$ Yes, mutation would be fine here. About the potential solution being more elaborate, I was looking for something like this: in order to match key b in a dictionary d = {'a': 'foo', 'b': 'bar', 'c': 'baz'} it is sufficient to do case {'b': 'bar', **rest}:. Of course, it is easy for dicts as the keys are unique, and was historically thought of as unordered. But I was just checking if there were some new ways introduced in the new syntax that would make matching middle elements possible. \$\endgroup\$ – Cyttorak Jun 7 at 18:20
  • \$\begingroup\$ why not make the variable left and right instead of lft and rgt \$\endgroup\$ – hjpotter92 Jun 8 at 7:02
3
\$\begingroup\$

I like your implementation as a whole, I do not think the if part you mentioned is the problem. [_, *middle, _] is a bigger problem as it is expensive to split the list over and over again.

Similarly if we are talking about performance using count is a tad lazy. What if our list contain thousand of values, and the list starts of as [3,5,...] count would count every five in the list, instead of returning after you found your desired number of fives.

In a similar vein I really despite hardcoded variables. What if you suddenly needed to test 3 and 2 instead of 4 and 5. Or maybe you needed at least 3 fours or 2 fives. The dict lookups are performed as O(1)

from pprint import pprint

VALUES = [4, 5]
STARTS_WITH = {VALUES[0]: "l", VALUES[1]: "l"}
ENDS_WITH = {VALUES[0]: "r", VALUES[1]: "r"}
START_AND_ENDS = "b1"
START_OR_ENDS = "b2"
MATCH_AT_LEAST_X_VALUES = "t"
AT_LEAST_MATCH = {VALUES[0]: 2, VALUES[1]: 1}
NO_MATCH = "x"


def contains_at_least_n_equal_values(lst, value, counts):
    count = 0
    for elem in lst:
        if elem == value:
            count += 1
            if count >= counts:
                return True
    return False


def decorate(data):
    def symbol_2_append(first_digit, middle_digits, last_digit):
        if first_digit in STARTS_WITH:
            if last_digit in ENDS_WITH:
                return START_AND_ENDS if first_digit == last_digit else START_OR_ENDS
            else:
                return STARTS_WITH[first_digit]
        elif last_digit in ENDS_WITH:
            return ENDS_WITH[last_digit]
        # If VALUES occurs at least X times in the the middle digits
        # we return some string. X is defined in AT_LEAST_MATCH
        for value in VALUES:
            n = AT_LEAST_MATCH[value]
            if contains_at_least_n_equal_values(middle_digits, values, n):
                return MATCH_AT_LEAST_X_VALUES
        return NO_MATCH

    new_data = []

    for i, (first_digit, *middle_digits, last_digit) in enumerate(data):
        symbol = symbol_2_append(first_digit, middle_digits, last_digit)
        new_data.append([symbol, first_digit, *middle_digits, last_digit])

    return new_data


if __name__ == "__main__":

    data = [  # index
        [1, 2, 5, 3, 4],  #   0
        [7, 5, 8, 4, 9],  #   1
        [2, 3, 4, 4, 5],  #   2
        [1, 3, 1, 6, 7],  #   3
        [5, 6, 0, 7, 8],  #   4
        [4, 3, 0, 7, 5],  #   5
        [4, 4, 4, 5, 4],  #   6
        [5, 2, 9, 3, 5],  #   7
    ]

    new_data = decorate(data)

    pprint(new_data)
\$\endgroup\$
1
  • \$\begingroup\$ I don't like hardcoded variables either, however in my case (i.e. my actual program to which the above is an MRE), hardcoded vars is the reasonable way, as it is guaranteed there's a limited set of vars. About the [_, *middle, _] part, I could directly count over inner_list, or could have avoided count altogether saving me iterating the list twice, but here I wanted it to be kind of suggestive that, I am looking for something that would match the pattern as is, without having to do count or if. It seems like there isn't a way to match such a pattern. \$\endgroup\$ – Cyttorak Jun 7 at 18:35

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.