Functional PHP

Over the course of the past week, I had a chance to write some brand new PHP code and I took the opportunity to try writing in a (mostly) purely functional style. The experience turned out to be very pleasant and definitely helped us to accomplish our goals as we figured out the details and changes the project needed.

A different approach

  • Instead of fearing the overhead in PHP for function calls, I tried to split everything out into separate functions: one responsibility per function.
  • Operations were performed immutably, though this didn’t have a big impact on the code. Practically speaking, this meant a preference for `map`, `reduce`, and `filter` operations which also eliminate off-by-one and null reference errors.
  • As I wrote the functions, I focussed on composability and modularity so that I could move the pieces around without making big changes elsewhere.

Different results

  • Unlike my normal code, these programs ended up with a large flat array of functions. I normally prefer to have fewer functions and more order, but this level of unitized functionality ended up being quite the boon, making changes to program behavior effortless. In the few cases we needed to modify what the program did or fix a design bug, it was usually around a couple of lines of changes, mostly moving function calls into different orders.
  • There are relatively few conditionals and very few variables in the code. For the most part, there are only variables where PHP forces me to use them to accomplish my goal, such as in obtaining the output from calling a process with `exec()`. Breaking down the functions has this effect. First of all, because the functions are often the callbacks for mapping operations, there’s no need to check if the passed-in arguments exist, which I would normally have to do if they were passed an array instead. Secondly, because the functions actually perform such a specific task, there’s little need to store the intermediate state within.
  • There are zero errors from PHP Mess Detector and PHP Code Sniffer. The entire file has a Cycolmatic Complexity of 9 for 186 lines and a maintainability index of 67. I think it would be higher if it were commented more, but the code itself is fairly well self-documenting. Update: By adding PHPDoc comments, I was able to boost the maintainability index from 67 to  83, proving that there’s always a way to game the system. If you examine the code, you can judge for yourself how necessary they are.

Conclusion

Like I mentioned above, modifying this (and another) program was incredibly easy. Taking the time up-front to define the interface certainly helped me to be able to make this do almost exactly what it needed to the first time I had it “finished.” Because the functions were so small, it was simple to gauge whether or not they would work as expected, then it was just a matter of stringing them together. Like a well-tested project, this one made it a breeze to find the spot of the one bug we found, preventing me from spending so much time hunting it down. The functional approach definitely lends itself well to building reasonable, effective, and comprehensive tests. It was so easy working with it at times that it kind of made me all tingly inside.


#!/usr/local/bin/php
<?php
/**
* Generates a structured list of files related to an
* SVN commit or working directory, or generates the
* diff between a particular SVN commit and its parent
* or between the working directory and the base revision.
*
* Calling:
* ./generate-linter-args.php [-rREVISION_NUMBER] –type ['changeset' or 'diff'] [file list]
*
* Get current changes:
* ./generate-linter-args.php –type changeset
* ./generate-linter-args.php –type diff
*
* Get changes for specific revision:
* ./generate-linter-args.php -r12345 –type changeset
* ./generate-linter-args.php -r12345 –type diff
*
* Get changes for specific files in working directory
* ./generate-linter-args.php –type changeset "wp-content/lib/class.splines.php" "wp-content/lib/class.reticulator.php"
* ./generate-linter-args.php –type diff "wp-content/lib/class.splines.php" "wp-content/lib/class.reticulator.php"
*/
/**
* Will return an item if it's a member of an array
* or `null` if it doesn't exist within that array.
*
* @param Array $array Containing array
* @param mixed $member Key to check for existence in the array
* @return bool Whether or not $member is in $array
*/
function maybeMember( Array $array, $member ) {
return isset( $array[ $member ] ) ? $array[ $member ] : null;
}
/**
* Returns the shell arguments assuming they are what
* they should be. Does not perform input validation.
*
* @return Array [ type of info requested, revision or null, list of filenames or blank ]
*/
function getShellArgs() {
global $argv;
$options = getopt( 'r::', [ 'type:' ] );
// Which parameter to create – required
$type = maybeMember( $options, 'type' );
// Revision is either given or "current"
$revision = (int) maybeMember( $options, 'r' );
// Get the remaining args after the script name and params
// Should be a space-separated list of filenames
$filenames = getRealFiles( array_slice( $argv, 1 ) );
return [ $type, $revision, $filenames ];
}
/**
* Returns only files that exist.
* Filters out special names that might also
* be svn commands coming through.
*
* @param Array $names List of potential filenames
* @return Array List of filenames for files that exist
*/
function getRealFiles( $names ) {
$isIrrelevant = function( $name ) {
return ! array_key_exists ( $name,
[
'commit' => true,
'ci' => true
] );
};
$files = $names;
$files = array_filter( $files, $isIrrelevant );
$files = array_filter( $files, 'file_exists' );
return $files;
}
/**
* Returns only lines that start with the specified character.
*
* @param string $type The letter describing which svn operation is desired
* @return Closure produces a bool output for the passed line if it starts with $type
*/
function typeFilter( $type ) {
return function( $line ) use ( $type ) {
return $type === substr( $line, 0, 1 );
};
}
/**
* Converts the `svn status` output line into a filename
*
* @param string $line one line of output from `svn status`
* @return string Trimmed version of $line without the status info prefix
*/
function statusLineToFilename( $line ) {
return trim( substr( $line, 7 ) );
}
/**
* Returns an array of files of a specified type
*
* @param Array $lines lines of output from `svn status`
* @param string $type Which type of `svn` operation to return
* @return Array Filenames for files that exist and match the $type predicate
*/
function filesByType( $lines, $type ) {
return array_values( array_map( 'statusLineToFilename', array_filter( $lines, typeFilter( $type ) ) ) );
}
/**
* Splits the output of `svn status` into a structured
* object with added, modified, missing, and deleted files
* as arrays.
*
* @param Array $lines output lines from `svn status`
* @return stdClass Structured view of changed files
*/
function statusLinesToChangeSet( $lines ) {
return (object) [
"addedFiles" => array_merge(
filesByType( $lines, 'A' ),
filesByType( $lines, 'R' )
),
"deletedFiles" => filesByType( $lines, 'D' ),
"missingFiles" => filesByType( $lines, '!' ),
"modifiedFiles" => filesByType( $lines, 'M' )
];
}
/**
* Computes the changeset between two revisions
*
* @param int $revision Specific revision to examine
* @return Array output from svn command
*/
function statusAcrossRevisions( $revision ) {
if ( empty( $revision ) ) {
$revision = getCurrentRevision();
}
exec( sprintf(
'svn diff -c%d –summarize', $revision
), $output, $exitStatus );
if ( 0 !== $exitStatus ) {
exit( $exitStatus );
}
return $output;
}
/**
* Computes the changeset assuming we are basing the
* comparison off of the local working copy.
*
* @param Array $filenames possible list of filenames to only examine
* @return Array output from svn command
*/
function statusForWorkingCopy( $filenames ) {
exec( sprintf( 'svn status %s 2>/dev/null',
implode( ' ', array_map( 'escapeshellarg', $filenames ) )
), $output, $exitStatus );
if ( 0 !== $exitStatus ) {
exit( $exitStatus );
}
return $output;
}
/**
* Returns the current revision number for the checked-out
* copy of the svn repository.
*
* @return int Revision of current local working svn repository
*/
function getCurrentRevision() {
exec( 'svn info | grep "Revision" | awk \'{print $2}\'', $raw_revision );
return (int) array_shift( $raw_revision );
}
/**
* Returns the output of `svn diff` across revisions
*
* @param int $revision A specific svn revision to examine
* @return Array output from svn command
*/
function createRevisionDiff( $revision ) {
if ( empty( $revision ) ) {
$revision = getCurrentRevision();
}
exec( sprintf(
'svn diff -c%d 2>/dev/null', $revision
), $output, $exitStatus );
if ( 0 !== $exitStatus ) {
exit( $exitStatus );
}
return $output;
}
/**
* Returns the output of `svn diff` for the working copy
*
* @param Array $filenames List of filenames to only examine
* @return Array output from svn command
*/
function createWorkingCopyDiff( $filenames ) {
exec( sprintf( 'svn diff %s 2>/dev/null',
implode( ' ', array_map( 'escapeshellarg', $filenames ) )
), $output, $exitStatus );
if ( 0 !== $exitStatus ) {
exit( $exitStatus );
}
return $output;
}
/**
* Returns the desired `svn` diff
*
* @param int $revision A specific revision to examine
* @param Array $filenames Specific list of files to examine only
* @return string Newline-terminated list of output lines for `svn diff`
*/
function createDiff( $revision, $filenames ) {
return implode( PHP_EOL,
empty( $revision )
? createWorkingCopyDiff( $filenames )
: createRevisionDiff( $revision )
);
}
/**
* Computes the desired changeset
*
* @param int $revision A specific revision to examine
* @param Array $filenames Specific list of files to examine only
* @return string JSON-encoded structured list of changed files for commit
*/
function listRelevantFiles( $revision, $filenames ) {
return json_encode( statusLinesToChangeSet(
empty( $revision )
? statusForWorkingCopy( $filenames )
: statusAcrossRevisions( $revision )
) );
}
/**
* Generates args suitable for passing into the linters
*
* @return int Whether or not the program was able to complete successfully
*/
function main() {
list( $type, $revision, $filenames ) = getShellArgs();
if ( empty( $type ) ) {
die( 'Please choose type of information requested: "–type changeset" or "–type diff"' );
}
if ( 'changeset' === $type ) {
echo listRelevantFiles( $revision, $filenames );
}
if ( 'diff' === $type ) {
echo createDiff( $revision, $filenames );
}
echo "\n";
}
main();

1 thought on “Functional PHP

  1. Reblogged this on Snell Family Adventures and commented:

    A technical post for those interested in “Functional Programming” and PHP

Leave a Reply

%d
search previous next tag category expand menu location phone mail time cart zoom edit close