How to reduce Cognitive Complexity in this Python method
Question:
I am faced with a challenge.
I have an Python method implemented and the SonarLint plugin of my PyCharm warns me with the message: "Refactor this function to reduce its Cognitive Complexity from 19 to the 15 allowed." but I can’t see how to reduce the complexity.
My Python method is:
def position(key):
if key == 'a':
return 0
elif key == 'b':
return 1
elif key == 'c':
return 2
elif key == 'd':
return 3
elif key == 'e':
return 4
elif key == 'f':
return 5
elif key == 'g':
return 6
elif key == 'h':
return 7
elif key == 'i':
return 8
elif key == 'j':
return 9
elif key == 'k':
return 10
elif key == 'l':
return 11
elif key == 'm':
return 12
elif key == 'n':
return 13
elif key == 'ñ':
return 14
elif key == 'o':
return 15
elif key == 'p':
return 16
elif key == 'q':
return 17
else:
logger.info('error')
And the warning of SonarLint is:
And if I click on show issue locations it gives me the explanation of how the Cognitive Complexity is calculated:
I can’t see how to reduce the complex of this function. I know that I can implement another method with the same behaviour using things like the ascii code, but it’s not the point of this question.
The summary of the question is how can I follow the suggestion of SonarLint, I mean, how can I reduce the Cognitive Complexity from 19 to the 15 of this particular method.
Something I’ve noticed is that if I remove elif statements until I have only 14 characters cases, the warning magically disappears.
Answers:
I’ve found what’s happening. The Plugin SonarLint has a maximum number of Cognitive Complexity, as you can see in this capture:
So SonarLint doesn’t say you that you can simplify your method, it tells you that this method is more complex than the prefixed limit that SonarLint has setted.
This is the reason because if I delete elif until I reach the magic number of 15 for the Cognitive Complexity the warning magically disappears.
¡¡WARNING!! It’s not recommendable to increase the SonarLint limit of Cognitive Complexity. The most advisable option is to refactor your method and find another way to do the same.
In my case I’ve implemented the next method instead (there are another shorter solutions, but because I have to use spanish alphabet I decided to use what for mi is the most readable solution):
def position(key: str) -> (int, None):
char_map = {
'a': 0,
'b': 1,
'c': 2,
'd': 3,
'e': 4,
'f': 5,
'g': 6,
'h': 7,
'i': 8,
'j': 9,
'k': 10,
'l': 11,
'm': 12,
'n': 13,
'ñ': 14,
'o': 15,
'p': 16,
'q': 17,
'r': 18,
's': 19,
't': 20,
'u': 21,
'v': 22,
'w': 23,
'x': 24,
'y': 25,
'z': 26
}
try:
output = char_map[key]
except KeyError:
logger.error('KeyError in position() with the key: ' + str(key))
return None
return output
You can refactor this to look some thing like this:
def position(key):
values ="abcdefghijklmnñopq"
try:
return values.index(key)
except Exception as e:
print(e) #you can use logger here if you want
>>> position("a")
0
>>> position("z")
substring not found
(Edit: this approach breaks because of the "ñ"
character, but I’ll leave it visible in case someone wants to use the regular alphabet.)
You could use ord
to turn the key into a number, and enforce a valid range of keys without having to specify "abcdefghijklmnop"
like some other answers suggest.
Hardcoded range:
def position(key:str) -> int:
value = ord(key) - ord("a")
if 0 <= value <= ord("p") - ord("a"):
return value
else:
logger.info('error')
Variable range with default values set to "a"
and "p"
:
def position(key:str; min_key="a", max_key="p") -> int:
value = ord(key) - ord(min_key)
if 0 <= value <= ord(max_key) - ord(min_key):
return value
else:
logger.info('error')
What SonarLint is detecting is a "code smell" that suggests you might be able to write your code in a more efficient and understandable way. It doesn’t mean it’s an actual problem, just that you might want to take a closer look. That’s what linters are for, after all.
In this case, it’s right. If I see a tower of if
statements, I have to read them all to make sure the code is doing what I think it’s doing. In fact, I suspect that it’s doing something non-obvious because otherwise, why write it like that? (And also, it’s very easy to make a mistake when writing the code, which can be very hard to find and debug.) Even using a dictionary, which would be an improvement here, can have this problem.
In fact, because it’s a tower of if
s, I didn’t notice for two weeks that there was an ñ
in there! And here I am updating my answer to account for it.
In general, a better way is to exploit the pattern in the data. In the following code it is easy to see that we are translating characters to integers, and that a
is zero and the subsequent letters use the next numbers in sequence. You don’t have to look at what each letter translates to. It is much easier to read and understand.
def position(key, offset=ord("a")):
pos = ord(key) - offset
if 0 <= pos <= 17:
return pos
logger.info("error")
Unfortunately, a version that deals with the ñ
is slightly more complicated.
def position(key, offset=ord("a")):
if key = "ñ":
return 14
pos = ord(key) - offset
if 0 <= pos <= 13:
return pos
if 14 <= pos <= 16:
return pos + 1
logger.info("error")
This may or may not be an improvement on a dictionary lookup, but at least it is clear at a glance that ñ
receives special treatment.
Since code is written once but may be read many many times, it pays to optimize for readability, even (usually) over performance. Remember, other people will be reading your code… and "you in six months" counts as "other people."
P.S. A possible upside of the original code is that if you need to figure out what number each letter translates to, or vice versa, you can see that a lot more easily than in a pattern-exploiting version. If that’s important to you, especially if the mapping is not obvious, using a dictionary is still usually a better solution.
I am faced with a challenge.
I have an Python method implemented and the SonarLint plugin of my PyCharm warns me with the message: "Refactor this function to reduce its Cognitive Complexity from 19 to the 15 allowed." but I can’t see how to reduce the complexity.
My Python method is:
def position(key):
if key == 'a':
return 0
elif key == 'b':
return 1
elif key == 'c':
return 2
elif key == 'd':
return 3
elif key == 'e':
return 4
elif key == 'f':
return 5
elif key == 'g':
return 6
elif key == 'h':
return 7
elif key == 'i':
return 8
elif key == 'j':
return 9
elif key == 'k':
return 10
elif key == 'l':
return 11
elif key == 'm':
return 12
elif key == 'n':
return 13
elif key == 'ñ':
return 14
elif key == 'o':
return 15
elif key == 'p':
return 16
elif key == 'q':
return 17
else:
logger.info('error')
And the warning of SonarLint is:
And if I click on show issue locations it gives me the explanation of how the Cognitive Complexity is calculated:
I can’t see how to reduce the complex of this function. I know that I can implement another method with the same behaviour using things like the ascii code, but it’s not the point of this question.
The summary of the question is how can I follow the suggestion of SonarLint, I mean, how can I reduce the Cognitive Complexity from 19 to the 15 of this particular method.
Something I’ve noticed is that if I remove elif statements until I have only 14 characters cases, the warning magically disappears.
I’ve found what’s happening. The Plugin SonarLint has a maximum number of Cognitive Complexity, as you can see in this capture:
So SonarLint doesn’t say you that you can simplify your method, it tells you that this method is more complex than the prefixed limit that SonarLint has setted.
This is the reason because if I delete elif until I reach the magic number of 15 for the Cognitive Complexity the warning magically disappears.
¡¡WARNING!! It’s not recommendable to increase the SonarLint limit of Cognitive Complexity. The most advisable option is to refactor your method and find another way to do the same.
In my case I’ve implemented the next method instead (there are another shorter solutions, but because I have to use spanish alphabet I decided to use what for mi is the most readable solution):
def position(key: str) -> (int, None):
char_map = {
'a': 0,
'b': 1,
'c': 2,
'd': 3,
'e': 4,
'f': 5,
'g': 6,
'h': 7,
'i': 8,
'j': 9,
'k': 10,
'l': 11,
'm': 12,
'n': 13,
'ñ': 14,
'o': 15,
'p': 16,
'q': 17,
'r': 18,
's': 19,
't': 20,
'u': 21,
'v': 22,
'w': 23,
'x': 24,
'y': 25,
'z': 26
}
try:
output = char_map[key]
except KeyError:
logger.error('KeyError in position() with the key: ' + str(key))
return None
return output
You can refactor this to look some thing like this:
def position(key):
values ="abcdefghijklmnñopq"
try:
return values.index(key)
except Exception as e:
print(e) #you can use logger here if you want
>>> position("a")
0
>>> position("z")
substring not found
(Edit: this approach breaks because of the "ñ"
character, but I’ll leave it visible in case someone wants to use the regular alphabet.)
You could use ord
to turn the key into a number, and enforce a valid range of keys without having to specify "abcdefghijklmnop"
like some other answers suggest.
Hardcoded range:
def position(key:str) -> int:
value = ord(key) - ord("a")
if 0 <= value <= ord("p") - ord("a"):
return value
else:
logger.info('error')
Variable range with default values set to "a"
and "p"
:
def position(key:str; min_key="a", max_key="p") -> int:
value = ord(key) - ord(min_key)
if 0 <= value <= ord(max_key) - ord(min_key):
return value
else:
logger.info('error')
What SonarLint is detecting is a "code smell" that suggests you might be able to write your code in a more efficient and understandable way. It doesn’t mean it’s an actual problem, just that you might want to take a closer look. That’s what linters are for, after all.
In this case, it’s right. If I see a tower of if
statements, I have to read them all to make sure the code is doing what I think it’s doing. In fact, I suspect that it’s doing something non-obvious because otherwise, why write it like that? (And also, it’s very easy to make a mistake when writing the code, which can be very hard to find and debug.) Even using a dictionary, which would be an improvement here, can have this problem.
In fact, because it’s a tower of if
s, I didn’t notice for two weeks that there was an ñ
in there! And here I am updating my answer to account for it.
In general, a better way is to exploit the pattern in the data. In the following code it is easy to see that we are translating characters to integers, and that a
is zero and the subsequent letters use the next numbers in sequence. You don’t have to look at what each letter translates to. It is much easier to read and understand.
def position(key, offset=ord("a")):
pos = ord(key) - offset
if 0 <= pos <= 17:
return pos
logger.info("error")
Unfortunately, a version that deals with the ñ
is slightly more complicated.
def position(key, offset=ord("a")):
if key = "ñ":
return 14
pos = ord(key) - offset
if 0 <= pos <= 13:
return pos
if 14 <= pos <= 16:
return pos + 1
logger.info("error")
This may or may not be an improvement on a dictionary lookup, but at least it is clear at a glance that ñ
receives special treatment.
Since code is written once but may be read many many times, it pays to optimize for readability, even (usually) over performance. Remember, other people will be reading your code… and "you in six months" counts as "other people."
P.S. A possible upside of the original code is that if you need to figure out what number each letter translates to, or vice versa, you can see that a lot more easily than in a pattern-exploiting version. If that’s important to you, especially if the mapping is not obvious, using a dictionary is still usually a better solution.