Swift & runtimeHistorical technical note
iOS refactoring notes: Extract Method
Applies the Extract Method technique from Refactoring to split long stretches of code into functions organised by intent. Parameters, local variables and return values are handled step by step, with examples showing how to reduce repetition and improve readability.
Originally credited to SindriLin; edited and archived by Bison.
Source listed in the originalOn this page
Editor: Bison
Contributor: SindriLin
Introduction
These are essentially my reading notes. Recently I have been reading Martin Fowler Refactoring: Improving the Design of Existing Code, and decided to replace the book’s examples with familiar code. For the definition of refactoring, I quote the book directly:
Refactoring changes code to improve internal structure without changing externally observable behaviour. It is a disciplined, proven approach to restructuring that minimises the chance of introducing errors. Essentially, it improves the design of code after it has been written.
Why learn refactoring? Primarily to deepen our understanding of software engineering and improve our skills.
Extract Method
Extract Method is a common refactoring technique. If code needs a comment explaining its purpose, extract it into an independent function. The steps are:
Create a new function and name it for its intent—what it does, rather than how. For example, for safe array access without an out-of-bounds index, I would choose func safeObjectAtIndex(index: Int), not func MakeIndexLessThanCountToGetObject(index: Int). It is a slightly silly example.
-
Copy the extracted code from the original function into the new target function.
-
Inspect the extracted code for local variables or parameters from the original function.
-
Check whether the new function changes any local variable. If so, see whether the extracted code can become a query returning the changed value. If that is difficult, use a different refactoring approach.
-
Pass the original local variables or parameters needed by the extracted code into the new function.
-
After handling all local variables, compile and test.
1. Initial code
func printOwing() {
var outstanding: CGFloat = 0.0
// print banner
print("********************************")
print("******* Customer Owes ********")
print("********************************")
// calculate outstanding
for order in _orders {
outstanding += order.amount
}
// print details
print("name: \(name)")
print("amount: \(outstanding)")
}
2. Extract single-purpose blocks
This includes extracting single-purpose code, such as logging, and simple functions accepting original local variables as arguments. Rename those arguments inside the extracted functions where useful.
func printOwing() {
var outstanding: CGFloat = 0.0
printBanner()
// calculate outstanding
for order in _orders {
outstanding += order.amount
}
printDetails(outstanding)
}
func printBanner() {
print("********************************")
print("******* Customer Owes ********")
print("********************************")
}
func printDetails(amount: CGFloat) {
print("name: \(name)")
print("amount: \(amount)")
}
3. Reassign local variables
For local variables involved in calculations, first check whether they change in both the original function and extracted code. If not, return the local value from the extracted function.
func printOwing() {
printBanner()
var outstanding: CGFloat = getOutstanding()
printDetails(outstanding)
}
func printBanner() {
print("********************************")
print("******* Customer Owes ********")
print("********************************")
}
func printDetails(amount: CGFloat) {
print("name: \(name)")
print("amount: \(amount)")
}
func getOutstanding() -> CGFloat {
var result: CGFloat = 0.0
for order in _orders {
result+= order.amount
}
return result
}
P.S. Swift has tuples, but avoid making a function return a tuple of many different values, since that reduces reusability.
A practical example
Today, a custom tableView footer needed to resize when its data source changed, with a minimum height. It has a weak reference to tableView named belongView. Screen height is kScreenHeigth and total navigation-bar height is kNavigationBarHeight, which changes when Personal Hotspot is active. The minimum height depends on the bottom addButton position. Here are my refactoring steps:
1. Initial code
func updateViewHeight() {
let tableView: UITableView! = belongView!
var headerHeight = tableView.tableViewHeaderView?.frame.height
headerHeight = (headerHeight == nil ? 0 : headerHeight)
let cellCount = tableView.dataSource.numberOfRowsInSection(tableView: tableView, section: 0)
var viewHeight: CGFloat = kScreenHeight - kNavigationBarHeight - CGFloat(cellCount) * tableView.rowHeight - headerHeight
let buttonBottomDistance: CGFloat = 15.0
let minHeight: CGFloat = CGRectGetMaxY(addButton) + buttonBottomDistance
self.frame.height = max(minHeight, viewHeight)
}
2. Extract functions to obtain local values
First extract the cell count, header height and minimum height.
func updateViewHeight() {
let headerHeight: CGFloat = getTableViewHeaderViewHeight()
let cellCount: Int= getTableViewCellsCount()
var viewHeight: CGFloat = kScreenHeight - kNavigationBarHeight - CGFloat(cellCount) * belongView!.rowHeight - headerHeight
let minHeight: CGFloat = getViewMinHeight()
self.frame.height = max(minHeight, viewHeight)
}
func getTableViewHeaderViewHeight() -> CGFloat {
var headerHeight = 0.0
if belongView!.tableViewHeaderView {
headerHeight = (belongView!.tableViewHeaderView?.height)!
}
return headerHeight
}
func getTableViewCellsCount() -> Int {
return (belongView!.dataSource.tableView(tableView: belongView!, numberOfRowsInSection: 0))!
}
func getViewMinHeight() -> CGFloat {
let buttonBottomDistance: CGFloat = 15.0
return CGRectGetMaxY(addButton) + buttonBottomDistance
}
3. Extract repeated functionality again
The cell count is only needed to calculate the height of all cells, so extract getCellsTotalHeight to obtain that total.
func updateViewHeight() {
let headerHeight: CGFloat = getTableViewHeaderViewHeight()
var viewHeight: CGFloat = kScreenHeight - kNavigationBarHeight - getCellsTotalHeight() - headerHeight
let minHeight: CGFloat = getViewMinHeight()
self.frame.height = max(minHeight, viewHeight)
}
func getTableViewHeaderViewHeight() -> CGFloat {
var headerHeight = 0.0
if belongView!.tableViewHeaderView {
headerHeight = (belongView!.tableViewHeaderView?.height)!
}
return headerHeight
}
func getCellsTotalHeight() -> CGFloat {
let cellCount: Int= getTableViewCellsCount()
return CGFloat(cellCount) * belongView!.rowHeight
}
func getTableViewCellsCount() -> Int {
return (belongView!.dataSource.tableView(tableView: belongView!, numberOfRowsInSection: 0))!
}
func getViewMinHeight() -> CGFloat {
let buttonBottomDistance: CGFloat = 15.0
return CGRectGetMaxY(addButton) + buttonBottomDistance
}
4. Extract related functionality into functions
updateViewHeight updates the footer’s height, but still contains height calculations. Extract those final calculations into getAdjustHeight.
func updateViewHeight() {
self.frame.height = getAdjustHeight()
}
func getAdjustHeight() -> CGFloat {
let headerHeight: CGFloat = getTableViewHeaderViewHeight()
var viewHeight: CGFloat = kScreenHeight - kNavigationBarHeight - getCellsTotalHeight() - headerHeight
let minHeight: CGFloat = getViewMinHeight()
return max(viewHeight, minHeight)
}
func getTableViewHeaderViewHeight() -> CGFloat {
var headerHeight = 0.0
if belongView!.tableViewHeaderView {
headerHeight = (belongView!.tableViewHeaderView?.height)!
}
return headerHeight
}
func getCellsTotalHeight() -> CGFloat {
let cellCount: Int= getTableViewCellsCount()
return CGFloat(cellCount) * belongView!.rowHeight
}
func getTableViewCellsCount() -> Int {
return (belongView!.dataSource.tableView(tableView: belongView!, numberOfRowsInSection: 0))!
}
func getViewMinHeight() -> CGFloat {
let buttonBottomDistance: CGFloat = 15.0
return CGRectGetMaxY(addButton) + buttonBottomDistance
}
5. Replace temporary variables with extracted functions
Finally, turn the necessary local variables into functions and replace the temporary height-calculation variables with function calls. updateViewHeight still contains calculations beyond its purpose of updating the footer height; extract these into getAdjustHeight.
func updateViewHeight() {
self.frame.height = getAdjustHeight()
}
func getAdjustHeight() -> CGFloat {
var viewHeight: CGFloat = kScreenHeight - kNavigationBarHeight - getCellsTotalHeight() - getTableViewHeaderViewHeight()
return max(viewHeight, getViewMinHeight())
}
func getTableViewHeaderViewHeight() -> CGFloat {
var headerHeight = 0.0
if belongView!.tableViewHeaderView {
headerHeight = (belongView!.tableViewHeaderView?.height)!
}
return headerHeight
}
func getCellsTotalHeight() -> CGFloat {
return CGFloat(getTableViewCellsCount()) * belongView!.rowHeight
}
func getTableViewCellsCount() -> Int {
return (belongView!.dataSource.tableView(tableView: belongView!, numberOfRowsInSection: 0))!
}
func getViewMinHeight() -> CGFloat {
return CGRectGetMaxY(addButton) + getButtonBottomDistance()
}
func getButtonBottomDistance() -> CGFloat {
return 15.0
}
Done!
Recommended reading: Bison's guide to iOS multithreading, part 3