Defining and calling functions

Once a script grows past a screen, functions keep it readable: each one does one job under a clear name, and the main part of the script reads like a list of steps. The general idea, with parameters and arguments, is in Functions and Abstraction; the Bash basics are in Shell Scripting Basics › Functions. The same small function in both shells, calculating the cost of a meter reading:

get_cost() {
  local kwh=$1
  local rate_cents=$2
  echo $(( kwh * rate_cents / 100 ))
}

cost=$(get_cost 4521 28)
echo "cost: $cost EUR (whole euros only)"
cost: 1265 EUR (whole euros only)
function Get-Cost {
    param([int]$Kwh, [double]$Rate)
    $Kwh * $Rate
}

$cost = Get-Cost -Kwh 4521 -Rate 0.28
"cost: $cost EUR"
cost: 1265.88 EUR

The differences already show:

  • Bash defines a function with name() { … } (the portable POSIX form; function name { … } also works in Bash). Parameters are not declared: they arrive as $1, $2 and so on. A function is called like a command, with the arguments separated by spaces.
  • PowerShell names functions like cmdlets, Verb-Noun with an approved verb (Get, Set, New, Remove…), and declares named, typed parameters. A call looks exactly like a cmdlet call.
  • Bash arithmetic has only whole numbers, so the Bash version works in cents and loses the decimals; PowerShell calculates with .NET numbers.

In both shells a function must be defined before the line that calls it runs, so scripts usually put the functions at the top and the main code at the bottom.

Getting results out: output and return

Bash: print the data, return the status

A Bash function has two separate channels. Data goes to standard output, and the caller captures it with command substitution. return only sets the exit status, a number from 0 to 255, which makes a function usable as a condition in if (see Exit codes). Without return, the function returns the status of its last command.

is_incident() {
  [[ $1 == INC-* ]]          # the status of this test is the function's status
}

for id in INC-20419 CHG-0815; do
  if is_incident "$id"; then
    echo "$id: incident"
  else
    echo "$id: not an incident"
  fi
done

counter=0
bump() {
  counter=$(( counter + 1 ))
  echo "$counter"
}
bump > /dev/null
bump > /dev/null
value=$(bump)
echo "captured: $value, counter afterward: $counter"
INC-20419: incident
CHG-0815: not an incident
captured: 3, counter afterward: 2

The last line shows a trap: $(bump) runs the function in a subshell, a copy of the shell, so the third bump raised the copy's counter to 3 and the real counter stayed at 2. A function that must change script variables cannot also hand back its result through $( ). Bash variables are global by default, so declare a function's own variables with local; otherwise a helper that uses i or file quietly overwrites the caller's.

PowerShell: everything that is output is returned

In PowerShell, a function returns every value that it outputs and does not capture or discard, not just the value after return. return $x is shorthand for "output $x and leave the function". That makes small functions short ($Kwh * $Rate above is the result), but it also means that stray output ends up in the result, a bug known as output pollution:

function Get-OpenTicket {
    $list = [System.Collections.ArrayList]::new()
    $list.Add('INC-20419')
    $list.Add('INC-20420')
    'two tickets found'
    return $list
}

$result = Get-OpenTicket
"result has $($result.Count) items:"
$result
result has 5 items:
0
1
two tickets found
INC-20419
INC-20420

ArrayList.Add() returns the index of the new item, and those 0 and 1 became output, together with the status message. The fix is to discard values you do not need and to send messages to another stream:

function Get-OpenTicket {
    [CmdletBinding()]
    param()
    $list = [System.Collections.Generic.List[string]]::new()
    $list.Add('INC-20419')          # List[string].Add returns nothing
    $null = $list.Add('INC-20420')  # discarding explicitly is harmless
    Write-Verbose 'two tickets found'
    $list
}

$result = Get-OpenTicket
"result has $($result.Count) items: $($result -join ', ')"
result has 2 items: INC-20419, INC-20420

$null = …, [void](…) and … | Out-Null all throw a value away. Write-Verbose, Write-Warning and Write-Host write to other streams and are never part of the result (see Input and Output). Unlike Bash, PowerShell functions run in their own scope: variables created inside are local automatically, and assigning to a script variable from inside a function creates a new local one unless you write $script:counter.

Arguments in Bash

A positional parameter is an argument identified only by its position: $1 for the first, $2 for the second, ${10} (with braces) for the tenth. A script receives the arguments of its command line this way, and a function receives its own set while it runs. $# holds the number, and shift drops $1 and moves the others down one place. The full table is in Shell Scripting Basics › Arguments.

Passing all arguments on to another command or function is where quoting decides everything. Only "$@" passes each argument on exactly as it was given:

show_args() {
  echo "  $# arguments:"
  for a in "$@"; do echo "    [$a]"; done
}
forward_quoted()   { show_args "$@"; }
forward_unquoted() { show_args $@; }
forward_star()     { show_args "$*"; }

echo 'with "$@":'; forward_quoted   "M-1042 day" "M-1043 night"
echo 'with $@:';   forward_unquoted "M-1042 day" "M-1043 night"
echo 'with "$*":'; forward_star     "M-1042 day" "M-1043 night"
with "$@":
  2 arguments:
    [M-1042 day]
    [M-1043 night]
with $@:
  4 arguments:
    [M-1042]
    [day]
    [M-1043]
    [night]
with "$*":
  1 arguments:
    [M-1042 day M-1043 night]

$@ without quotes splits every argument at spaces (and expands wildcards in it), and "$*" glues all arguments into one string. "$*" has its uses, for example in a log message, but for passing arguments on it is always "$@". A missing argument is simply empty; ${2:-Day} supplies a default.

Options with getopts

Real admin scripts take options, like the commands they sit next to: clean-logs.sh -n -d 7 /var/log/kestrel for a dry run that would remove logs older than seven days. getopts is the Bash (and POSIX) builtin that parses such single-letter options. Called in a while loop with an option string, it returns one option per round in a variable, puts an option's value in $OPTARG, and keeps the index of the next argument in $OPTIND:

cat > clean-logs.sh <<'EOF'
#!/bin/bash
# clean-logs.sh - remove old log files (this demo only reports its settings)
# Usage: clean-logs.sh [-n] [-d DAYS] DIRECTORY
usage() {
  echo "Usage: $0 [-n] [-d DAYS] DIRECTORY" >&2
  exit 2
}

dry_run=false
days=30
while getopts ':nd:' opt; do
  case $opt in
    n)  dry_run=true ;;
    d)  days=$OPTARG ;;
    :)  echo "option -$OPTARG needs a value" >&2; usage ;;
    \?) echo "unknown option -$OPTARG" >&2; usage ;;
  esac
done
shift $(( OPTIND - 1 ))          # what is left are the other arguments

[[ $# -eq 1 ]] || usage
if [[ ! $days =~ ^[0-9]+$ ]]; then
  echo "DAYS must be a whole number, not '$days'" >&2
  exit 2
fi
echo "dry_run=$dry_run days=$days directory=$1"
EOF

bash clean-logs.sh -n -d 7 /var/log/kestrel
bash clean-logs.sh /var/log/kestrel
bash clean-logs.sh -d;                 echo "exit status $?"
bash clean-logs.sh -x /var/log/kestrel; echo "exit status $?"
bash clean-logs.sh -d week /tmp;       echo "exit status $?"
dry_run=true days=7 directory=/var/log/kestrel
dry_run=false days=30 directory=/var/log/kestrel
option -d needs a value
Usage: clean-logs.sh [-n] [-d DAYS] DIRECTORY
exit status 2
unknown option -x
Usage: clean-logs.sh [-n] [-d DAYS] DIRECTORY
exit status 2
DAYS must be a whole number, not 'week'
exit status 2
  • In the option string ':nd:', each letter is an option, and a colon after a letter means "takes a value". The leading colon switches getopts to silent mode: instead of printing its own messages, it reports a missing value as : and an unknown option as ?, so the script can print a clear message and its usage.
  • shift $(( OPTIND - 1 )) removes the parsed options, so $1 is the directory again.
  • Exit code 2 for wrong usage is a common convention (it is what grep and many other tools use).

getopts only knows short options; it cannot parse --days 7. For long options, use the separate getopt program from util-linux (on Linux; the macOS version is different), or parse the arguments yourself with a while loop, case and shift.

Parameters in PowerShell

A param block, param( … ), declares the parameters of a function or script: their names, types, default values and rules. PowerShell then does the parsing that getopts does in Bash, and more. It converts arguments to the declared types, fills in defaults, accepts any unambiguous abbreviation of a parameter name, and completes names with the Tab key. Arguments can be passed by name, in any order, or by position:

function Get-Bill {
    param(
        [string]$MeterId,
        [int]$Kwh,
        [string]$Tariff = 'Day'
    )
    $rate = if ($Tariff -eq 'Night') { 0.22 } else { 0.28 }
    [PSCustomObject]@{
        MeterId = $MeterId
        Kwh     = $Kwh
        Cost    = [math]::Round($Kwh * $rate, 2)
    }
}

Get-Bill -MeterId M-1042 -Kwh 4521
Get-Bill M-1043 312 -Tariff Night
MeterId  Kwh    Cost
-------  ---    ----
M-1042  4521 1265.88
M-1043   312   68.64

The function returns objects (PSCustomObject), not text, so the caller can sort, filter or export them, and PowerShell formats them as a table only at the end. A parameter of type [switch] is an on/off flag without a value, such as -Detailed; it is the PowerShell version of -n in the Bash script above. The same param() block at the very top of a .ps1 file declares the parameters of the script itself: ./Get-Bill.ps1 -MeterId M-1042 -Kwh 4521.

function Show-Reading {
    param([string]$MeterId, [int]$Kwh)
    "MeterId=[$MeterId] Kwh=[$Kwh]"
}
Show-Reading -MeterId M-1042 -Kwh 4521
Show-Reading M-1042 4521
Show-Reading('M-1042', 4521)
MeterId=[M-1042] Kwh=[4521]
MeterId=[M-1042] Kwh=[4521]
MeterId=[M-1042 4521] Kwh=[0]

Parentheses and commas are only for .NET methods such as $list.Add('INC-20419') or [math]::Round($x, 2). Functions and cmdlets take arguments separated by spaces, preferably with their names: in scripts, named parameters keep working when someone adds a parameter later, and they explain themselves to the next reader.

Validating parameters

Every value a script receives from a user, a file or another system should be checked before it is used (see input validation). In PowerShell, a parameter validation attribute states the rule in the param block, and PowerShell checks it before the function body runs:

Attribute Rule
[Parameter(Mandatory)] The parameter must be given (PowerShell prompts for it when run interactively)
[ValidateSet('Day', 'Night')] The value must be one of the list (case-insensitive); also feeds Tab completion
[ValidatePattern('^M-\d{4}$')] The value must match the regular expression
[ValidateRange(0, 100000)] The number must lie in the range
[ValidateScript({ Test-Path $_ })] The script block must return True for the value
[ValidateNotNullOrEmpty()] The value must not be $null or empty
function Get-Bill {
    param(
        [Parameter(Mandatory)]
        [ValidatePattern('^M-\d{4}$')]
        [string]$MeterId,

        [ValidateRange(0, 100000)]
        [int]$Kwh = 0,

        [ValidateSet('Day', 'Night')]
        [string]$Tariff = 'Day'
    )
    "$MeterId $Kwh kWh ($Tariff)"
}

Get-Bill -MeterId M-1042 -Kwh 4521 -Tariff Night
Get-Bill -MeterId X1 -Kwh 10
Get-Bill -MeterId M-1043 -Kwh -5
Get-Bill -MeterId M-1043 -Tariff Weekend
M-1042 4521 kWh (Night)
Get-Bill: /home/amara/scripts/example.ps1:17
Line |
  17 |  Get-Bill -MeterId X1 -Kwh 10
     |                    ~~
     | Cannot validate argument on parameter 'MeterId'. The argument "X1" does
     | not match the "^M-\d{4}$" pattern. Supply an argument that matches
     | "^M-\d{4}$" and try the command again.
Get-Bill: /home/amara/scripts/example.ps1:18
Line |
  18 |  Get-Bill -MeterId M-1043 -Kwh -5
     |                                ~~
     | Cannot validate argument on parameter 'Kwh'. The -5 argument is less
     | than the minimum allowed range of 0. Supply an argument that is greater
     | than or equal to 0 and then try the command again.
Get-Bill: /home/amara/scripts/example.ps1:19
Line |
  19 |  Get-Bill -MeterId M-1043 -Tariff Weekend
     |                                   ~~~~~~~
     | Cannot validate argument on parameter 'Tariff'. The argument "Weekend"
     | does not belong to the set "Day,Night" specified by the ValidateSet
     | attribute. Supply an argument that is in the set and then try the
     | command again.

Each failed call stopped before the function body ran, with a message that names the parameter and the rule. These errors do not stop the script, though; the next line still runs. Make them stop it with $ErrorActionPreference = 'Stop' or -ErrorAction Stop (see Error Handling).

Bash has no declarative validation, so you write the checks at the top of the function yourself, with [[ =~ ]] and case, an error message on standard error, and a non-zero status:

get_bill() {
  local meter_id=$1 kwh=$2 tariff=${3:-Day}
  if [[ ! $meter_id =~ ^M-[0-9]{4}$ ]]; then
    echo "get_bill: invalid meter ID '$meter_id'" >&2
    return 2
  fi
  case $tariff in
    Day | Night) ;;
    *) echo "get_bill: tariff must be Day or Night, not '$tariff'" >&2
       return 2 ;;
  esac
  echo "$meter_id $kwh kWh ($tariff)"
}

get_bill M-1042 4521 Night
get_bill X1 10
get_bill M-1043 312 Weekend || echo "get_bill failed with status $?"
M-1042 4521 kWh (Night)
get_bill: invalid meter ID 'X1'
get_bill: tariff must be Day or Night, not 'Weekend'
get_bill failed with status 2

Advanced functions

An advanced function is a PowerShell function with the [CmdletBinding()] attribute above its param block. It then behaves like a compiled cmdlet: it gets the common parameters (-Verbose, -Debug, -ErrorAction, -ErrorVariable, -WarningAction and others), rejects parameters it does not know, and can take pipeline input. With [CmdletBinding(SupportsShouldProcess)] it also gets -WhatIf and -Confirm (see Writing Robust and Safe Scripts).

Pipeline input works through the parameter attribute: with ValueFromPipelineByPropertyName, a property of the incoming object fills the parameter of the same name. The function body is then split into blocks: begin runs once, process once per incoming object, and end once at the end, just like the blocks of ForEach-Object.

function Get-Bill {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipelineByPropertyName)]
        [string]$MeterId,

        [Parameter(Mandatory, ValueFromPipelineByPropertyName)]
        [int]$Kwh
    )
    begin {
        $rate = 0.28
        Write-Verbose "rate is $rate EUR/kWh"
    }
    process {
        Write-Verbose "billing $MeterId"
        '{0}: {1:N2} EUR' -f $MeterId, ($Kwh * $rate)
    }
}

$readings = @(
    [PSCustomObject]@{ MeterId = 'M-1042'; Kwh = 4521 }
    [PSCustomObject]@{ MeterId = 'M-1043'; Kwh = 312 }
)
$readings | Get-Bill
'--- with -Verbose:'
$readings | Get-Bill -Verbose
M-1042: 1,265.88 EUR
M-1043: 87.36 EUR
--- with -Verbose:
VERBOSE: rate is 0.28 EUR/kWh
VERBOSE: billing M-1042
M-1042: 1,265.88 EUR
VERBOSE: billing M-1043
M-1043: 87.36 EUR

Write-Verbose messages stay hidden until the caller adds -Verbose, which makes them the right place for "what am I doing" details: silent in a scheduled run, available when someone troubleshoots. Bash's nearest equivalent is a -v option parsed with getopts that switches a log function on, or set -x, which traces every command.

Splatting and argument arrays

Commands with many parameters make long, hard-to-read lines, and scripts often need to add a parameter only in some cases. Splatting solves both in PowerShell: you put the parameters in a hash table, with the parameter names as keys, and pass it with @ instead of $. An array splats positional arguments the same way.

function Get-Bill {
    param([string]$MeterId, [int]$Kwh, [string]$Tariff = 'Day')
    "$MeterId $Kwh kWh ($Tariff)"
}

$billParams = @{
    MeterId = 'M-1042'
    Kwh     = 4521
}
$nightRate = $true
if ($nightRate) { $billParams.Tariff = 'Night' }
Get-Bill @billParams

$fileParams = @{ Path = 'report.txt'; Encoding = 'utf8' }
Set-Content @fileParams -Value 'Kestrel Energy daily report'
Add-Content @fileParams -Value 'meters read: 3'
Get-Content -Path report.txt
M-1042 4521 kWh (Night)
Kestrel Energy daily report
meters read: 3

Bash has no splatting, but an array of arguments does the same job, as long as you expand it with "${args[@]}" so that every element stays one argument, spaces included:

printf '%s\n' 'ERROR meter M-1042 timeout' 'error meter M-1044 timeout' \
  'INFO export done' > "app log.txt"

args=(--count)
ignore_case=true
if [[ $ignore_case == true ]]; then
  args+=(--ignore-case)
fi
args+=(-- error "app log.txt")

echo "running: grep ${args[*]}"
grep "${args[@]}"
running: grep --count --ignore-case -- error app log.txt
2

Building a command as one string, as in cmd="grep $opts error $file" followed by $cmd or eval "$cmd", breaks on the first file name with a space and is dangerous with input you do not control. Arrays avoid both problems.

Side by side

Topic Bash PowerShell
Define name() { …; } function Verb-Noun { param(…) … }
Parameters $1 … ${10}, $#, "$@" Named and typed in param()
Call get_bill M-1042 4521 Get-Bill -MeterId M-1042 -Kwh 4521
Result Print it; capture with $( ) Everything uncaptured is output
Success or failure return N (0–255) Errors (Write-Error, throw)
Local variables Only with local By default (function scope)
Options getopts (short options only) param() with [switch], defaults, names
Validation Your own [[ =~ ]] and case checks Validation attributes
Pass many arguments cmd "${args[@]}" Cmd @params (splatting)