Pages

Friday 25 January 2013

Writing PowerShell functions properly

My roots are in IT infrastructure. I have never been a full on developer so my understanding of syntax or the proper objects and methods to use is not complete. However, I do try to do whatever I do in the cleanest manner. This includes my ever growing addiction with PowerShell.

I've been using PowerShell for a few years now. Started out slowly with the odd command here and there and eventually got a big push forward when I forced myself to sit down and write my first actual script file. Happiest day of my scripting life!
Ever since then I've been trying to find various best practises and good examples for whatever I try to do. The most common search I perform is for proper PowerShell function declaration. To me, more than anything, this is the holy grail pinpointing the main difference between PowerShell and most other languages.
PowerShell is a language aimed at IT personnel and not developers. This target audience knows little of best practises, proper syntax or code hardening. They are usually under budgeted and do not have the time for proper coding. In addition, they usually also lack the expertise required to properly estimate a coding project.
The above audience make up the main bulk of PowerShell article and blog writers. And herein lies the problem. The blind are leading the blind.

Whenever I try searching for a properly written PowerShell function I usually find articles explaining how simple it is to write these in PowerShell. Those articles usually contain functions similar to my first example below.

The following function (and all those to follow) are simples ones that take in 3 string values and combine them together with some additional text. I deliberately picked a simple example.

First, this base function example.
Function WriteText ($a, $b, $c) {
    Write-Host $a 'was first,' $b 'was second,' $c 'was third'
}

Some of you may be asking yourself what's wrong with this function. Well, quite a lot. I'll get to that in a moment. Let me start off by executing the above function:
WriteText 'ABC' 'DEF' 'GHI'

With the following output:
ABC was first, DEF was second, GHI was third

Great! That's what we wanted isn't it?
Yes, but only on a very limited basis.
What would happen if instead of providing 3 strings we only provided 2?
WriteText 'ABC' 'GHI'

With the following output:
ABC was first, GHI was second, was third

Now hang on there PowerShell window! That's not what I wanted you to do!
Clearly 'ABC' was supposed to be the first string and 'GHI' was the third. I don't even know what I expected you to do with the missing second one. That should have returned an error, right?
Wrong. All wrong.

Scripts (like other code) only do what you tell them to do. Nothing more and nothing less. So where did we go wrong with this function?
Oh so many places! Let's start listing some of them.
  1. Our function's name does not follow PowerShell's hyphened double worded function format. Examples of proper functions would be Get-Item, Set-ExecutionPolicy etc.
  2. Our variables are not typed hardened. This could cause all sorts of issues in longer code segments where we expect something to work which only works with specific types.
  3. Our general syntax uses the simplest possible PowerShell syntax. While this is fast to write, it is less readable if we later on go back and want to make alterations or explain it to others.

My next example is the very same function with some syntax and type hardening alterations. Still not a very robust function but at least it's readable. It now also has a meaningful function name which follows proper PowerShell best practises for naming. I even added my own 'La' prefix to the function so it would not clash with other similarly named functions in the future.
Function Get-LaStringConcat1 {
    Param (
        [String]$FirstString,
        [String]$SecondString,
        [String]$ThirdString
    )
    Process {
        Write-Host $FirstString 'was first,' $SecondString 'was second,' $ThirdString 'was third'
    }
}

I can now easily execute this function in the same way I executed the previous one but would like to show you additional changes to the way I like working. In order to avoid mishaps you should always strive to execute functions in a parametrised manner. This is demonstrated here (and will be the guideline for all the following examples).
Get-LaStringConcat1 -FirstString 'ABC' -SecondString 'DEF' -ThirdString 'GHI'

This returns the following output:
ABC was first, DEF was second, GHI was third

If I were to take out one of the strings, I would still get undesired results.
Example:
Get-LaStringConcat1 -FirstString 'ABC' -ThirdString 'GHI'

Output:
ABC was first,  was second, GHI was third


Now let's move on and fix the undesired results. The first and easiest way to fix this is by requiring all 3 parameters contain a value. Simple enough to do, we just need to make the following changes to our function.
Function Get-LaStringConcat2 {
    Param (
        [parameter(Mandatory=$true)]
        [validateNotNull()]
        [String]
        $FirstString,
        [parameter(Mandatory=$true)]
        [validateNotNull()]
        [String]
        $SecondString,
        [parameter(Mandatory=$true)]
        [validateNotNull()]
        [String]
        $ThirdString
    )
    Process {
        Write-Host $FirstString 'was first,' $SecondString 'was second,' $ThirdString 'was third'
    }
}

The above function implemented a very addition to all 3 parameters. The first option indicates to PowerShell that the parameter is mandatory so it cannot be omitted anymore. The second option tells PowerShell that even if the parameter is not omitted it must still contain a non-null value. It can be executed in the same way as before. I will skip the valid execution and go straight to one with missing parameters. Let's provide the first and second parameters but leave out the third.
Get-LaStringConcat2 -FirstString 'ABC' -SecondString 'CDE'

Output:
cmdlet Get-LaStringConcat2 at command pipeline position 1
Supply values for the following parameters:
ThirdString:

That's good isn't it? PowerShell noticed I did not supply the ThirdString parameter and is now demanding it of me. What if I provide no value and just hit Enter? Well, this happens:
Get-LaStringConcat2 : Cannot bind argument to parameter 'ThirdString' because it is an empty string.
At line:1 char:1
+ Get-LaStringConcat2 -FirstString 'ABC' -SecondString 'CDE'
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : InvalidData: (:) [Get-LaStringConcat2], ParameterBindingValidationException
    + FullyQualifiedErrorId : ParameterArgumentValidationErrorEmptyStringNotAllowed,Get-LaStringConcat2
Why is that? Because I also asked PowerShell to make sure any supplied values are not null.


Right, 'are we done?' you may be asking. Far from it (and my apologies for the long post).
While the above example does harden our function so it can't be executed with missing parameters, I would prefer it be more flexible and forgiving. For this reason I came up with the following example.
Function Get-LaStringConcat3 {
    Param (
        [String]$FirstString,
        [String]$SecondString,
        [String]$ThirdString
    )
    Process {
        [String]$OutStr = ''

        If ($FirstString)  {$OutStr += $FirstString + ' was first, '}
        If ($SecondString) {$OutStr += $SecondString + ' was second, '}
        If ($ThirdString) {$OutStr += $ThirdString + ' was third'}
        
        If ($OutStr -eq '') {$OutStr = 'No input strings were provided'}
        
        Write-Host $OutStr
    }
}

Wait a second, the function just got a lot longer. Why? I made the following changes from the previous one:
  1. Removed the [parameter(Mandatory=$true)] and [validateNotNull()] options. I do want to allow running the function with missing parameters as this is a desired feature.
  2. As parameters can now be empty, I need to check each one and build up my output string accordingly. That's what the whole code block in Process{} does. The function will now create a blank output string variable and start adding to it for each non-null variable.
  3. If no parameters at all were provided the function will output the 'No input strings were provided' message.
Executing the above function with a missing parameter:
Get-LaStringConcat3 -FirstString 'ABC' -ThirdString 'GHI'

ABC was first, GHI was third

Now that was good! Our function finally looks good even with missing parameters! How about no parameters at all?
Get-LaStringConcat3


No input strings were provided

Perfect! Now my function is ready to be shipped out so the entire company can use it!

Well, not quite yet. There's just one more adjustment I want to make to it. It's a very subtle one this time. Note how our function uses Write-Host for outputting the compiled string. The string isn't returned as the function's value but is just printed to screen as text. What if I wanted to store that text in a variable for later use? Not easily done with our current function. Here's what happens if I try.
$SomeString = Get-LaStringConcat3 -FirstString 'ABC' -ThirdString 'GHI'


ABC was first, GHI was third

Note how the variable value assigning output the string. But did it store it?
Write-Host $SomeString

Output:


No value. Our variable is empty because the function didn't return a value, it just printed to screen.

Now that's not good. I want to use this function in long scripts and must be able to store its output value.
So let's make a very small change to our function. In fact, it's only changing a single word. Can you spot it?
Function Get-LaStringConcat4 {
    Param (
        [String]$FirstString,
        [String]$SecondString,
        [String]$ThirdString
    )
    Process {
        [String]$OutStr = ''

        If ($FirstString)  {$OutStr += $FirstString + ' was first, '}
        If ($SecondString) {$OutStr += $SecondString + ' was second, '}
        If ($ThirdString) {$OutStr += $ThirdString + ' was third'}
        
        If ($OutStr -eq '') {$OutStr = 'No input strings were provided'}
        
        Return $OutStr
    }
}

Did you spot it? All I did was change the Write-Host command to a Return command. Return is a special command which can only be used inside a function and tells the function which data to return when executed. Let's have a look at the previous example again:
$SomeString = Get-LaStringConcat4 -FirstString 'ABC' -ThirdString 'GHI'




Now that's more like it. Storing a value in a variable shouldn't output any data unless I specifically ask for it. Now how about the actual variable? Does it have the string stored?
Write-Host $SomeString

Output:
ABC was first, GHI was third

Success!
Now the function can be shipped off and used in other scripts by anyone. I'd call this hardened enough for a more distributed use.

To be fair, we're not really done. There are still a lot of small bug fixed to perform with our function. Our 3 strings aren't put together smoothly enough, we haven't added any in-line help to our function, there is no proper documentation for what each line does (very important!) and many other small and annoying things to do. For the purpose of what I wanted to show you today I'll stop here. The rest you can do on your own. :)

***Update 30/01/2013
+Peter Kriegel suggested I also include the the [cmdletbinding()] option in this blog post. Seeing as it does not add too much complication, I decided to.
Instead of transposing other wonderful articles I decided to just link them here. I suggest a read through of these:

1 comment:

Unknown said...

Article updated with the [cmdletbinding()] option.