Storytelling with Code
and living happily ever after
Goals of this talk:
- Provide practical tips for writing more readable code - regardless of your preferred programming language
- Show how writing readable code usually means you're writing more testable and maintainable code too!
- Encourage questions and participation
- Live happily ever after
Delaine Wendling
Software Engineer
Who's reading your code anyway?
Who's reading your code anyway?
Readable code is:
If I can easily, and quickly, reason about the code and explain it to non-technical people or other developers - I would say the code is highly readable.
A little caveat before we dive in:
The Road to Readable Code
and living happily ever after
What story does this tell?
if not cache.get('key_36.1627_86.7816'):
geo_place = GeoService().get_place(
36.1627,
86.7816,
)
cache.set(
'key_36.1627_86.7816',
geo_place,
21600,
)
Is this a better story?
latitude = 36.1627
longitude = 86.7816
place_cache_key = f"key_{latitude}_{longitude}"
cached_place = cache.get(place_cache_key)
place_cache_timeout = 60 * 60 * 6
if not cached_place:
place_from_geo_service = GeoService().get_place(
latitude,
longitude,
)
cache.set(
place_cache_key,
place_from_geo_service,
place_cache_timeout,
)
Telling a Better Story Tip 1:
Create context using variables with meaningful names
(long names are ok but not TOO long)
place_cache_key
place_cache_key_with_latitude_and_longitude
Think not
l_1 = 36.1627
l_2= 86.7816
key = f"key_{l_1}_{l_2}"
x = cache.get(place_cache_key)
t = 60 * 60 * 6
if not cached_place:
z = GeoService().get_place(
l_1,
l_2,
)
cache.set(
x,
z,
t,
)
Telling a Better Story Tip 2:
Avoid naked numbers
Telling a Better Story Tip 2:
Avoid naked numbers
You can even write a number as a mathematical expression to provide context (i.e. CACHE_TIMOUT = 60 * 60 * 6 rather than CACHE_TIMOUT = 21600)
password_length = len(password)
if password_length < 8:
raise ValidationError("""Must create a
password that is 8 characters or more""")
MIN_PASSWORD_LENGTH = 8
password_length = len(password)
if password_length < MIN_PASSWORD_LENGTH:
raise ValidationError(f"""Must create a
password that is {MIN_PASSWORD_LENGTH}
characters or more""")
Naked Number Alert!
Much better :)
def validate_password(password):
password_length = len(password)
if password_length < 8:
raise ValidationError("""Must create a
password that is 8 characters or more""")
Testing with a naked number
def test_validate_password(self):
password = ''.join(
random.choices(string.ascii_lowercase,
k=7,
))
with self.assertRaises(ValidationError) as e:
validate_password(password)
self.assertEqual(e.exception.errors[0],
"""Must create a password that is
8 characters or more""")
def validate_password(password):
password_length = len(password)
if password_length < MIN_PASSWORD_LENGTH:
raise ValidationError(f"""Must create a
password that is {MIN_PASSWORD_LENGTH}
characters or more""")
Testing with a constant (variable)
def test_validate_password(self):
password = ''.join(
random.choices(string.ascii_lowercase,
k=MIN_PASSWORD_LENGTH - 1,
))
with self.assertRaises(ValidationError) as e:
validate_password_length(password)
self.assertEqual(e.exception.errors[0],
f"""Must create a password that is
{MIN_PASSWORD_LENGTH} characters or more""")
Telling a Better Story Tip 3:
Break up larger chunks of code into smaller methods/functions (that do one thing)
but not too small or too many
Parsing the CSV
Cleaning the data
Hitting the database
def handle(self, file_path, *args, **kwargs):
pages_data = parse_pages_csv(file_path)
cleaned_pages = clean_pages_data(pages_data)
create_or_update_pages(cleaned_pages)
Few rules of thumb:
- If you are tempted to put the word "and" in the name of a function or method, it's probably doing more than one thing and you should make more than one function or method.
- Re-evaluate if you have a call stack of methods that have almost the exact same name
fetchEvent()
fetchEventFromApi()
getEvent()
prepareEvent()
This is a real example
Telling a Better Story Tip 4:
Think of your tests as another source of documentation for your code
import re
def increment_string(strng):
# The following regex only finds numbers at the end of a string
# (numbers in the middle of the string will not be found)
ending_num_group = re.search(r'\d+$', strng)
if ending_num_group:
ending_num = ending_num_group.group()
new_ending_num = int(ending_num) + 1
num_length = len(ending_num)
last_index_before_num = len(strng)-num_length
# zfill returns a copy of the string with
# '0' characters padded to the left
new_string = strng[:last_index_before_num] +
str(new_ending_num).zfill(num_length)
else:
new_string = strng + '1'
return new_string
@parameterized.expand([
('no number at the end', 'slug', 'slug1'),
('20 at end', 'adfe20', 'adfe21'),
('100 at end', 'slug100', 'slug101'),
('number at beginning', '100-club', '100-club1'),
('number at beginning and end', '100-club-2', '100-club-3'),
('number in the middle', 'jointhe100club', 'jointhe100club1'),
(
'number in the middle and end with zeroes',
'jointhe100club002',
'jointhe100club003',
),
])
def test_increment_string(self, _test_name, string_input, expected):
result = increment_string(string_input)
self.assertEqual(result, expected)
Telling a Better Story Tip 5:
Verbosity (not complexity) > Cleverness
Write simple functions with specific names. Remove anything “clever”.
If you’re trying to achieve something in the least amount of code lines possible, it most likely won’t be easily readable.
increment_string=f=lambda s:s and s[-1].isdigit()and(f(s[:-1])+"0",
s[:-1]+str(int(s[-1])+1))[s[-1]<"9"]or s+"1"
Your job is to write a function which increments a string, to create a new string.
- If the string already ends with a number, the number should be incremented by 1.
- If the string does not end with a number, the number 1 should be appended to the new string.
increment_string=f=lambda s:s and s[-1].isdigit()and(f(s[:-1])+"0",
s[:-1]+str(int(s[-1])+1))[s[-1]<"9"]or s+"1"
Your job is to write a function which increments a string, to create a new string.
- If the string already ends with a number, the number should be incremented by 1.
- If the string does not end with a number. the number 1 should be appended to the new string.
import re
def increment_string(strng):
# The following regex only finds numbers at the end of a string
# (numbers in the middle of the string will not be found)
ending_num_group = re.search(r'\d+$', strng)
if ending_num_group:
ending_num = ending_num_group.group()
new_ending_num = int(ending_num) + 1
num_length = len(ending_num)
last_index_before_num = len(strng)-num_length
# zfill returns a copy of the string with
# '0' characters padded to the left
new_string = strng[:last_index_before_num] +
str(new_ending_num).zfill(num_length)
else:
new_string = strng + '1'
return new_string
Telling a Better Story Tip 6:
Comments and Docstrings
when needed
Cleverness is just another spectrum, it’s not always good and it’s not always bad, there’s a tradeoff.
If I have something clever that has advantages, other than simply being clever, I would consider possibly documenting it rather than just throwing it out
def get_full_place_name(self, place_type, current_place):
"""
Formats the place name according to the following rules:
* Cities
- '{city}, {region}' for cities in the United States and Australia
- '{city}, {country}' for cities in other countries
* Regions - '{region}, {country}'
* Countries - '{country}'
e.g.
* Nashville, TN and Mendoza, Argentina
* California, United States
* Iceland
:param place_type: Indicates what kind of place it is
(i.e. city, region)
:type place_type: string
:param current_place: A dictionary of information about the
current place
(e.g. {
'city': 'Nashville',
'region': 'Tennessee',
'country': 'United States',
})
:type current_place: dict
:returns place_name: Returns the correctly formatted place name
according to the rules listed above.
:rtype: string
Telling a Better Story Tip 7:
Follow the style guide and norms of the language you're using and your place of work
Telling a Better Story Tip 8:
Avoid deep nesting
- Use boolean operators to flatten out the structure
- Create methods/functions
- Use built-in methods
if 'DELETE' in key and value:
if 'DELETE' in key:
if value:
VERSUS
Telling a Better Story Tip 9:
INDENTATION!
<table>
<tr>
<td> Lorem ipsum dolor sit amet
</td>
<td>
Consectetur adipisicing
</td>
</tr>
<tr>
<td>
Lorem ipsum dolor sit amet
</td>
<td>
Consectetur adipisicing
</td>
</tr>
</table>
def get_context_data(self, **kwargs):
place_id = kwargs.get('place_id')
enrollments = kwargs.get('enrollments', {})
content = self.get_content(place_id,
should_enable_city_browse_carousel)
buckets = content.get('buckets')
location_slug = kwargs.get('location_slug')
context = { 'place_id': place_id,
enrollments: enrollments, content: content,
buckets: buckets,
location_slug: location_slug,}
return context
Telling a Better Story Tip 10:
Association > Inheritance
I would argue against using mixins/multiple inheritance. They make supporting code like a magical nightmare web where you have no idea where functionality is coming from. I prefer explicit composition where possible. This makes a clearly defined trail you can follow to find functionality.
Association is a relationship between two separate classes which is established through their Objects
Aggregation represents a Has-A relationship.
Code reuse is high with this architecture.
Objects are independent of one another.
Composition represents a Part-Of relationship.
Objects are dependent on one another.
Questions?
Resources:
Small Functions Considered Harmful: https://medium.com/@copyconstruct/small-functions-considered-harmful-91035d316c29
Association, aggregation and composition. https://www.geeksforgeeks.org/association-composition-aggregation-java/
Advice and quotes from my lovely co-workers
Storytelling with Code 06-08-2019
By Delaine Wendling
Storytelling with Code 06-08-2019
- 1,041