String Processing
Lesson 19: String Processing
🎯 Life Analogy
Imagine you are a librarian. Every day you handle a large amount of text work:
- Search: Check if a book title contains a certain keyword →
strings.Contains - Replace: Replace old labels with new ones →
strings.Replace - Split: Separate a comma-delimited list of tags →
strings.Split - Join: Combine multiple keywords into a search query →
strings.Join - Trim: Remove extra spaces from the beginning and end of a book title →
strings.Trim
String processing is the "text work" in programs — almost every program needs to deal with text.
Core Concepts
Go provides several standard libraries for handling strings:
| Package | Purpose | Common Functions |
|---|---|---|
strings |
String search, replace, split, join, etc. | Contains, Replace, Split, Join, Trim |
strconv |
Conversion between strings and other types | Atoi, Itoa, ParseBool, FormatFloat |
unicode/utf8 |
UTF-8 encoding related operations | RuneCountInString, ValidString |
strings.Builder |
Efficient concatenation of many strings | WriteString, String |
Key Points:
- Strings in Go are immutable — any modification creates a new string
- Strings in Go are UTF-8 encoded byte sequences at the lower level
len(str)returns the number of bytes, not charactersruneis Go's type for representing Unicode code points (essentiallyint32)
Basic Syntax and Usage
1. strings Package
package main
import (
"fmt"
"strings"
)
func main() {
str := "Hello, GoLang!"
// Search
fmt.Println(strings.Contains(str, "Go")) // true
fmt.Println(strings.HasPrefix(str, "Hello")) // true
fmt.Println(strings.HasSuffix(str, "!")) // true
fmt.Println(strings.Index(str, "Go")) // 7
// Replace
result := strings.Replace(str, "Go", "Golang", 1)
fmt.Println(result) // "Hello, GolangLang!"
// Replace all
s := "aabbcc"
fmt.Println(strings.ReplaceAll(s, "a", "x")) // "xxbbcc"
// Split and join
csv := "apple,banana,cherry"
fruits := strings.Split(csv, ",")
fmt.Println(fruits) // [apple banana cherry]
joined := strings.Join(fruits, " | ")
fmt.Println(joined) // "apple | banana | cherry"
// Trim
padded := " Hello World "
fmt.Println(strings.TrimSpace(padded)) // "Hello World"
fmt.Println(strings.Trim("##Hello##", "#")) // "Hello"
fmt.Println(strings.TrimLeft("##Hello##", "#")) // "Hello##"
// Case conversion
fmt.Println(strings.ToUpper("hello")) // "HELLO"
fmt.Println(strings.ToLower("HELLO")) // "hello"
// Repeat
fmt.Println(strings.Repeat("Go", 3)) // "GoGoGo"
// Count
fmt.Println(strings.Count("banana", "an")) // 2
}
strings.Split is an empty string, the string is split into a slice of individual characters.
2. strconv Package
package main
import (
"fmt"
"strconv"
)
func main() {
// String → Integer
num, err := strconv.Atoi("42")
if err != nil {
fmt.Println("Conversion failed:", err)
}
fmt.Println(num) // 42
// Integer → String
str := strconv.Itoa(42)
fmt.Println(str) // "42"
// String → Boolean
b, err := strconv.ParseBool("true")
fmt.Println(b, err) // true <nil>
// String → Float
f, err := strconv.ParseFloat("3.14", 64)
fmt.Println(f, err) // 3.14 <nil>
// Float → String
// 'f' means normal format, -1 means minimum digits, 64 means float64
s := strconv.FormatFloat(3.14, 'f', -1, 64)
fmt.Println(s) // "3.14"
// Formatted output (similar to C's sprintf)
formatted := strconv.FormatInt(255, 16) // Hexadecimal
fmt.Println(formatted) // "ff"
}
strconv.Atoi is equivalent to strconv.ParseInt(s, 10, 0), returning the platform-dependent int type.
3. unicode/utf8 Package
package main
import (
"fmt"
"unicode/utf8"
)
func main() {
str := "GoLangProg"
// len() returns the number of bytes
fmt.Println(len(str)) // 10 (each ASCII char takes 1 byte)
// utf8.RuneCountInString() returns the number of characters
fmt.Println(utf8.RuneCountInString(str)) // 10
// Check if it's valid UTF-8
fmt.Println(utf8.ValidString(str)) // true
fmt.Println(utf8.ValidString("abc")) // true
// Iterate over each rune in the string
for i, r := range str {
fmt.Printf("Index:%d Character:%c Unicode:%U\n", i, r, r)
}
}
range to iterate over a string, Go automatically iterates by rune (Unicode character), not by byte.
4. strings.Builder (Efficient Concatenation)
package main
import (
"fmt"
"strings"
)
func main() {
// ❌ Inefficient: creates a new string on every concatenation
// result := ""
// for i := 0; i < 1000; i++ {
// result += "a" // allocates new memory each time
// }
// ✅ Efficient: use strings.Builder
var builder strings.Builder
for i := 0; i < 1000; i++ {
builder.WriteString("a")
}
result := builder.String()
fmt.Println(len(result)) // 1000
// Pre-allocate capacity for even better performance
var builder2 strings.Builder
builder2.Grow(1000) // Pre-allocate 1000 bytes
for i := 0; i < 1000; i++ {
builder2.WriteString("b")
}
fmt.Println(builder2.Len()) // 1000
}
strings.Builder internally uses a []byte slice, avoiding frequent memory allocations caused by string immutability.
Example Code
Example: String Statistics and Analysis (Difficulty ⭐)
package main
import (
"fmt"
"strings"
"unicode"
)
// Analyze the counts of different character types in a string
func analyzeString(s string) (letters, digits, spaces, others int) {
for _, r := range s {
switch {
case unicode.IsLetter(r):
letters++
case unicode.IsDigit(r):
digits++
case unicode.IsSpace(r):
spaces++
default:
others++
}
}
return
}
func main() {
text := "Hello, GoLang! 2024yr Version 2.0"
letters, digits, spaces, others := analyzeString(text)
fmt.Printf("Text: %q\n", text)
fmt.Printf("Letters: %d\n", letters)
fmt.Printf("Digits: %d\n", digits)
fmt.Printf("Spaces: %d\n", spaces)
fmt.Printf("Others: %d\n", others)
// Count word frequency
words := strings.Fields("the go the language the world")
freq := make(map[string]int)
for _, w := range words {
freq[strings.ToLower(w)]++
}
fmt.Println("\nWord frequency:", freq)
}
Output:
Text: "Hello, GoLang! 2024yr Version 2.0"
Letters: 18
Digits: 7
Spaces: 5
Others: 2
Word frequency: map[go:1 language:1 the:3 world:1]
Example: CSV Parser (Difficulty ⭐⭐)
package main
import (
"fmt"
"strings"
)
// Simple CSV line parser supporting quoted fields
func parseCSVLine(line string) []string {
var fields []string
var current strings.Builder
inQuotes := false
for _, r := range line {
switch {
case r == '"' && !inQuotes:
// Enter quoted region
inQuotes = true
case r == '"' && inQuotes:
// Leave quoted region
inQuotes = false
case r == ',' && !inQuotes:
// Encountered delimiter, save current field
fields = append(fields, current.String())
current.Reset()
default:
// Normal character
current.WriteRune(r)
}
}
// Save the last field
fields = append(fields, current.String())
return fields
}
// Clean and format fields
func cleanFields(fields []string) []string {
cleaned := make([]string, len(fields))
for i, f := range fields {
cleaned[i] = strings.TrimSpace(f)
}
return cleaned
}
func main() {
// Simulated CSV data
csvData := []string{
`Alice,28,"Beijing, China"`,
`Bob,35,"New York, USA"`,
`Charlie,42,"London, UK"`,
}
fmt.Println("=== CSV Parse Results ===")
for _, line := range csvData {
fields := parseCSVLine(line)
fields = cleanFields(fields)
fmt.Printf("Name: %-10s Age: %-4s Location: %s\n",
fields[0], fields[1], fields[2])
}
// Reverse operation: join a slice into a CSV line
record := []string{"David", "30", "Shanghai, China"}
csvLine := strings.Join(record, ",")
fmt.Println("\nGenerated CSV line:", csvLine)
}
Output:
=== CSV Parse Results ===
Name: Alice Age: 28 Location: Beijing, China
Name: Bob Age: 35 Location: New York, USA
Name: Charlie Age: 42 Location: London, UK
Generated CSV line: David,30,Shanghai, China
Example: Template Engine (Difficulty ⭐⭐⭐)
package main
import (
"fmt"
"strconv"
"strings"
)
// Simple template engine: replaces {{key}} with corresponding values
func renderTemplate(template string, data map[string]string) string {
var result strings.Builder
result.Grow(len(template) * 2) // Estimate capacity
i := 0
for i < len(template) {
// Find "{{"
if i+1 < len(template) && template[i] == '{' && template[i+1] == '{' {
// Find corresponding "}}"
end := strings.Index(template[i+2:], "}}")
if end != -1 {
key := strings.TrimSpace(template[i+2 : i+2+end])
if value, ok := data[key]; ok {
result.WriteString(value)
} else {
// Key not found, keep as-is
result.WriteString("{{" + key + "}}")
}
i += end + 4 // Skip "}}"
continue
}
}
result.WriteByte(template[i])
i++
}
return result.String()
}
// Format table output
func formatTable(headers []string, rows [][]string) string {
// Calculate max width for each column
colWidths := make([]int, len(headers))
for i, h := range headers {
colWidths[i] = len(h)
}
for _, row := range rows {
for i, cell := range row {
if i < len(colWidths) && len(cell) > colWidths[i] {
colWidths[i] = len(cell)
}
}
}
var b strings.Builder
// Write header
for i, h := range headers {
b.WriteString(fmt.Sprintf("%-*s | ", colWidths[i], h))
}
b.WriteString("\n")
// Write separator line
for i := range headers {
b.WriteString(strings.Repeat("-", colWidths[i]) + "-+-")
}
b.WriteString("\n")
// Write data rows
for _, row := range rows {
for i, cell := range row {
if i < len(colWidths) {
b.WriteString(fmt.Sprintf("%-*s | ", colWidths[i], cell))
}
}
b.WriteString("\n")
}
return b.String()
}
// Convert a number string to different base representations
func toBases(numStr string) (map[string]string, error) {
num, err := strconv.ParseInt(numStr, 10, 64)
if err != nil {
return nil, err
}
return map[string]string{
"decimal": strconv.FormatInt(num, 10),
"binary": strconv.FormatInt(num, 2),
"octal": strconv.FormatInt(num, 8),
"hexadecimal": strconv.FormatInt(num, 16),
}, nil
}
func main() {
// 1. Template rendering
fmt.Println("=== Template Rendering ===")
template := "Hello, {{name}}! Welcome to {{city}}. You have {{count}} new messages."
data := map[string]string{
"name": "Alice",
"city": "Beijing",
"count": "5",
}
fmt.Println(renderTemplate(template, data))
// 2. Table formatting
fmt.Println("\n=== Table Formatting ===")
headers := []string{"Name", "Age", "City"}
rows := [][]string{
{"Alice", "28", "Beijing"},
{"Bob", "35", "New York"},
{"Charlie", "42", "London"},
}
fmt.Print(formatTable(headers, rows))
// 3. Base conversion
fmt.Println("\n=== Base Conversion ===")
bases, _ := toBases("255")
for name, value := range bases {
fmt.Printf("%-12s: %s\n", name, value)
}
}
Output:
=== Template Rendering ===
Hello, Alice! Welcome to Beijing. You have 5 new messages.
=== Table Formatting ===
Name | Age | City |
--------+-----+---------+-
Alice | 28 | Beijing |
Bob | 35 | New York|
Charlie | 42 | London |
=== Base Conversion ===
decimal : 255
binary : 11111111
octal : 377
hexadecimal : ff
Practical Application Scenarios
Scenario 1: Log Analyzer
package main
import (
"fmt"
"strconv"
"strings"
"time"
)
// Log entry structure
type LogEntry struct {
Timestamp string
Level string
Message string
Source string
}
// Parse a log line
// Format: [2024-01-15 10:30:00] [ERROR] Database connection failed (db-service)
func parseLogLine(line string) (*LogEntry, error) {
entry := &LogEntry{}
// Extract timestamp
if start := strings.Index(line, "["); start != -1 {
if end := strings.Index(line, "]"); end != -1 {
entry.Timestamp = line[start+1 : end]
line = line[end+1:]
}
}
// Extract log level
line = strings.TrimSpace(line)
if start := strings.Index(line, "["); start != -1 {
if end := strings.Index(line, "]"); end != -1 {
entry.Level = line[start+1 : end]
line = line[end+1:]
}
}
// Extract message and source
line = strings.TrimSpace(line)
if parenStart := strings.LastIndex(line, "("); parenStart != -1 {
if parenEnd := strings.LastIndex(line, ")"); parenEnd != -1 {
entry.Source = line[parenStart+1 : parenEnd]
entry.Message = strings.TrimSpace(line[:parenStart])
}
} else {
entry.Message = line
}
return entry, nil
}
// Analyze log levels
func analyzeLogs(entries []LogEntry) map[string]int {
stats := make(map[string]int)
for _, e := range entries {
stats[strings.ToUpper(e.Level)]++
}
return stats
}
// Filter logs containing a keyword
func filterLogs(entries []LogEntry, keyword string) []LogEntry {
var filtered []LogEntry
keyword = strings.ToLower(keyword)
for _, e := range entries {
if strings.Contains(strings.ToLower(e.Message), keyword) {
filtered = append(filtered, e)
}
}
return filtered
}
func main() {
// Simulated log data
logLines := []string{
"[2024-01-15 10:30:00] [INFO] Application started (main-service)",
"[2024-01-15 10:30:05] [INFO] Connected to database (db-service)",
"[2024-01-15 10:31:00] [WARN] High memory usage detected (monitor)",
"[2024-01-15 10:32:00] [ERROR] Database connection timeout (db-service)",
"[2024-01-15 10:32:01] [ERROR] Retry failed, switching to backup (db-service)",
"[2024-01-15 10:33:00] [INFO] Backup database connected (db-service)",
"[2024-01-15 10:35:00] [DEBUG] Cache cleared (cache-service)",
}
// Parse all logs
var entries []LogEntry
for _, line := range logLines {
entry, err := parseLogLine(line)
if err == nil {
entries = append(entries, *entry)
}
}
// Log level statistics
fmt.Println("=== Log Level Statistics ===")
stats := analyzeLogs(entries)
for level, count := range stats {
fmt.Printf(" %s: %d entries\n", level, count)
}
// Filter error logs
fmt.Println("\n=== Error Logs ===")
for _, e := range entries {
if strings.ToUpper(e.Level) == "ERROR" {
fmt.Printf(" %s | %s | %s\n", e.Timestamp, e.Message, e.Source)
}
}
// Search by keyword
fmt.Println("\n=== Logs containing 'database' ===")
filtered := filterLogs(entries, "database")
for _, e := range filtered {
fmt.Printf(" [%s] %s\n", e.Level, e.Message)
}
}
Output:
=== Log Level Statistics ===
INFO: 3 entries
WARN: 1 entries
ERROR: 2 entries
DEBUG: 1 entries
=== Error Logs ===
2024-01-15 10:32:00 | Database connection timeout | db-service
2024-01-15 10:32:01 | Retry failed, switching to backup | db-service
=== Logs containing 'database' ===
[INFO] Connected to database
[ERROR] Database connection timeout
[INFO] Backup database connected
Scenario 2: User Input Validation and Sanitization
package main
import (
"fmt"
"regexp"
"strconv"
"strings"
"unicode"
)
// Sanitize user input for username
func sanitizeUsername(name string) (string, error) {
// Remove leading and trailing spaces
name = strings.TrimSpace(name)
// Check length
if len(name) < 3 {
return "", fmt.Errorf("username too short (minimum 3 characters)")
}
if len(name) > 20 {
return "", fmt.Errorf("username too long (maximum 20 characters)")
}
// Only allow letters, digits, and underscores
var cleaned strings.Builder
for _, r := range name {
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' {
cleaned.WriteRune(r)
}
}
result := cleaned.String()
if len(result) < 3 {
return "", fmt.Errorf("too few valid characters")
}
return strings.ToLower(result), nil
}
// Validate and parse phone number (Mainland China)
func parsePhone(phone string) (string, error) {
// Remove all spaces and hyphens
phone = strings.ReplaceAll(phone, " ", "")
phone = strings.ReplaceAll(phone, "-", "")
// Check if it starts with +86
if strings.HasPrefix(phone, "+86") {
phone = phone[3:]
} else if strings.HasPrefix(phone, "86") {
phone = phone[2:]
}
// Validate length
if len(phone) != 11 {
return "", fmt.Errorf("incorrect phone number length: %d digits", len(phone))
}
// Validate all digits
for _, r := range phone {
if !unicode.IsDigit(r) {
return "", fmt.Errorf("phone number contains non-digit character: %c", r)
}
}
// Validate starts with 1
if !strings.HasPrefix(phone, "1") {
return "", fmt.Errorf("phone number must start with 1")
}
return phone, nil
}
// Parse size string with units
func parseSize(sizeStr string) (int64, error) {
sizeStr = strings.TrimSpace(strings.ToUpper(sizeStr))
// Extract numeric and unit parts
var numPart strings.Builder
var unitPart strings.Builder
for _, r := range sizeStr {
if unicode.IsDigit(r) || r == '.' {
numPart.WriteRune(r)
} else if unicode.IsLetter(r) {
unitPart.WriteRune(r)
}
}
num, err := strconv.ParseFloat(numPart.String(), 64)
if err != nil {
return 0, fmt.Errorf("invalid number: %s", numPart.String())
}
// Convert to bytes based on unit
unit := unitPart.String()
multipliers := map[string]int64{
"B": 1,
"KB": 1024,
"MB": 1024 * 1024,
"GB": 1024 * 1024 * 1024,
"TB": 1024 * 1024 * 1024 * 1024,
}
multiplier, ok := multipliers[unit]
if !ok {
return 0, fmt.Errorf("unknown unit: %s", unit)
}
return int64(num * float64(multiplier)), nil
}
func main() {
// Username sanitization tests
fmt.Println("=== Username Validation ===")
usernames := []string{" Alice_123 ", "ab", "A!@#B", "GoDeveloper2024"}
for _, u := range usernames {
result, err := sanitizeUsername(u)
if err != nil {
fmt.Printf(" %q → Error: %v\n", u, err)
} else {
fmt.Printf(" %q → %q\n", u, result)
}
}
// Phone number parsing tests
fmt.Println("\n=== Phone Number Parsing ===")
phones := []string{"138 0013 8000", "+86-138-0013-8000", "12345", "23800138000"}
for _, p := range phones {
result, err := parsePhone(p)
if err != nil {
fmt.Printf(" %q → Error: %v\n", p, err)
} else {
fmt.Printf(" %q → %s\n", p, result)
}
}
// File size parsing tests
fmt.Println("\n=== File Size Parsing ===")
sizes := []string{"1.5GB", "512MB", "1024KB", "100B", "2TB"}
for _, s := range sizes {
bytes, err := parseSize(s)
if err != nil {
fmt.Printf(" %s → Error: %v\n", s, err)
} else {
fmt.Printf(" %s → %d bytes\n", s, bytes)
}
}
}
Output:
=== Username Validation ===
" Alice_123 " → "alice_123"
"ab" → Error: username too short (minimum 3 characters)
"A!@#B" → Error: too few valid characters
"GoDeveloper2024" → "godeveloper2024"
=== Phone Number Parsing ===
"138 0013 8000" → 13800138000
"+86-138-0013-8000" → 13800138000
"12345" → Error: incorrect phone number length: 5 digits
"23800138000" → Error: phone number must start with 1
=== File Size Parsing ===
1.5GB → 1610612736 bytes
512MB → 536870912 bytes
1024KB → 1048576 bytes
100B → 100 bytes
2TB → 2199023255552 bytes
❓ FAQ
Q1: Why does len("GoLang") return 6 instead of 4?
package main
import (
"fmt"
"unicode/utf8"
)
func main() {
s := "GoLang"
// len() returns bytes, not characters
fmt.Println("len():", len(s)) // 6
// ASCII characters take 1 byte each in UTF-8
// G(1) + o(1) + L(1) + a(1) + n(1) + g(1) = 6
// Correct way to get character count
fmt.Println("RuneCountInString():", utf8.RuneCountInString(s)) // 6
// Or use range to count
count := 0
for range s {
count++
}
fmt.Println("range count:", count) // 6
}
Key Point: When handling multi-byte characters like CJK, always use utf8.RuneCountInString() or range to get the true character count.
Q2: String concatenation — use + or strings.Builder?
package main
import (
"fmt"
"strings"
)
func main() {
// A few concatenations: just use + (compiler optimizes)
s := "Hello" + " " + "World"
fmt.Println(s)
// Many concatenations: must use strings.Builder
var builder strings.Builder
for i := 0; i < 10000; i++ {
builder.WriteString("a")
}
fmt.Println("Length:", builder.Len())
// Pre-allocation further improves performance
var builder2 strings.Builder
builder2.Grow(10000) // Pre-allocate 10000 bytes
for i := 0; i < 10000; i++ {
builder2.WriteString("b")
}
fmt.Println("Length:", builder2.Len())
}
Rules of Thumb:
| Scenario | Recommended Approach |
|---|---|
| 2-3 string concatenations | + or fmt.Sprintf |
| Concatenation in loop (known count) | strings.Builder + Grow() |
| Concatenation in loop (unknown count) | strings.Builder |
Q3: How to check if a string contains only specific characters?
package main
import (
"fmt"
"strings"
"unicode"
)
func main() {
s := "Hello123"
// Check if it contains only letters and digits
isAlphanumeric := true
for _, r := range s {
if !unicode.IsLetter(r) && !unicode.IsDigit(r) {
isAlphanumeric = false
break
}
}
fmt.Println("Alphanumeric only:", isAlphanumeric)
// Check if it contains only ASCII letters
isASCII := true
for _, r := range s {
if r > 127 {
isASCII = false
break
}
}
fmt.Println("ASCII only:", isASCII)
// Check if it contains only a specific character set
allowed := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
containsOnlyAllowed := true
for _, r := range s {
if !strings.ContainsRune(allowed, r) {
containsOnlyAllowed = false
break
}
}
fmt.Println("Within allowed range:", containsOnlyAllowed)
}
Q4: When to use strings.Contains vs regular expressions?
package main
import (
"fmt"
"regexp"
"strings"
)
func main() {
text := "My email is test@example.com, phone is 13800138000"
// Simple search → use strings package (faster)
fmt.Println(strings.Contains(text, "example.com")) // true
// Pattern matching → use regular expressions
emailRegex := regexp.MustCompile(`[\w.]+@[\w.]+\.\w+`)
fmt.Println("Email:", emailRegex.FindString(text))
phoneRegex := regexp.MustCompile(`1[3-9]\d{9}`)
fmt.Println("Phone:", phoneRegex.FindString(text))
}
Guidelines:
- Fixed string search: Use the
stringspackage — better performance - Pattern matching (e.g., email, phone format): Use the
regexppackage - Avoid repeatedly compiling regular expressions inside loops; pre-compile them
📖 Summary
| Topic | Key Content |
|---|---|
| strings Package | Contains, HasPrefix, HasSuffix, Index, Replace, Split, Join, Trim, ToUpper, ToLower, Count, Repeat, Fields |
| strconv Package | Atoi, Itoa, ParseBool, ParseFloat, FormatInt, FormatFloat |
| unicode/utf8 | RuneCountInString, ValidString, unicode.IsLetter/IsDigit/IsSpace |
| strings.Builder | WriteString, WriteRune, WriteByte, Grow, String, Len |
| Core Principles | Strings are immutable, len returns bytes, range iterates by rune, use Builder for many concatenations |
📝 Exercises
Exercise 1: Basic — String Reversal
Write a function reverseString(s string) string that reverses a string. It must correctly handle Chinese characters.
Hint: You cannot simply convert the string to []byte and reverse it, because Chinese characters occupy multiple bytes.
// Expected results
reverseString("Hello") // "olleH"
reverseString("GoLang") // "gnaLGo"
Exercise 2: Intermediate — CamelCase and Snake_case Conversion
Write two functions:
camelToSnake(s string) string: Convert camelCase to snake_casesnakeToCamel(s string) string: Convert snake_case to camelCase
Hint: Use unicode.IsUpper to detect uppercase letter positions.
// Expected results
camelToSnake("helloWorld") // "hello_world"
camelToSnake("HTTPResponse") // "http_response"
snakeToCamel("hello_world") // "helloWorld"
snakeToCamel("http_response") // "httpResponse"
Exercise 3: Challenge — Simple Markdown Heading Extractor
Write a function extractHeadings(md string) []string that extracts all headings from a Markdown document.
Hint: Headings start with #, and the number of # symbols indicates the heading level.
// Input
md := `# Heading 1
This is body text
## Heading 2
### Heading 3
## Another Heading 2`
// Expected output
// ["# Heading 1", "## Heading 2", "### Heading 3", "## Another Heading 2"]
Next Lesson
Congratulations on completing string processing! In the next lesson, we will learn about File I/O Operations — how to read and write files, handle directories, and use buffered I/O for better performance.



