Functions and Parameters
Organizing scripts into functions, handling arguments with getopts in Bash, and param blocks, validation and advanced functions in PowerShell.
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,$2and 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$1is the directory again. -
Exit code 2 for wrong usage is a common convention (it is what
grepand 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) |