C# Part 5 – Streams and Serialization

In C#, Part 5 of the learning journey often focuses on streams and serialization. Streams, in this context, refer to the flow of data between an application and a source or destination, such as files, network connections, or memory. Understanding streams is crucial for efficient data handling, as they provide a means to read or write data in smaller chunks rather than loading entire files into memory.

Serialization, on the other hand, involves converting complex data structures or objects into a format suitable for storage or transmission, typically to disk or over a network. The process allows for data persistence and interchange between different applications or systems. C# provides various serialization techniques, including binary serialization, XML serialization, and more recently, JSON serialization.

Streams and Serialization

Managing Stream Resources in C#

Managing stream resources is a critical aspect of efficient and reliable programming, particularly when dealing with I/O operations. Stream resources, such as file handles or network connections, must be handled carefully to avoid resource leaks and performance issues.

The common practice involves using constructs like using statements in C# to ensure that streams are properly disposed of after use. Failing to do so can lead to memory leaks and potential resource exhaustion, which can impact the stability of an application.

Additionally, developers often implement error-handling mechanisms to gracefully handle exceptions that may occur during stream operations. Effective management of stream resources is essential for writing robust, resource-efficient, and scalable applications that interact with external data sources or perform I/O operations.

The Using Construct in C#

The “using” construct is a powerful feature that simplifies resource management and helps prevent resource leaks in a structured and efficient manner. It primarily serves two purposes: disposing of objects that implement the IDisposable interface and limiting the scope of variables.

One key role of the “using” statement is to automatically call the Dispose() method on objects that implement IDisposable when they go out of scope. This is crucial for objects like streams, database connections, and other resources that require explicit cleanup. By using “using,” developers can ensure that resources are released promptly, improving the overall reliability of their code, and preventing memory leaks.

Moreover, the “using” statement is employed to establish a limited scope for variables. This is particularly useful when dealing with resources that should only exist within a specific block of code. Once the block is exited, the “using” construct ensures that the resources are properly disposed of, thus freeing up memory and other system resources.

For example, when working with file streams, a “using” block can be utilized to create a stream, perform read or write operations, and automatically close and dispose of the stream when the block is exited. This simplifies the code and enhances its readability while ensuring proper resource management.

The IDisposable Interface in C#

The IDisposable interface in C# plays a vital role in resource management, offering a standardized way to ensure that objects that consume unmanaged resources, such as file handles, network connections, or database connections, are properly cleaned up when they are no longer needed. IDisposable is at the core of C#’s approach to deterministic resource cleanup.

One of the primary functions of IDisposable is to declare a single method, Dispose(). This method is responsible for releasing and disposing of any unmanaged resources held by an object.

When you no longer need an object that implements IDisposable, calling the Dispose() method explicitly allows you to release these resources immediately, rather than relying on the garbage collector to eventually clean them up. This is particularly important for resources with limited availability, as it ensures that they are returned promptly, preventing resource leaks and improving application efficiency.

To simplify the process of calling Dispose(), C# provides the “using” statement, as mentioned earlier. The “using” statement automatically calls Dispose() on objects that implement IDisposable when the block of code within the “using” statement is exited. This elegant construct reduces the burden on developers to remember to dispose of objects explicitly and makes it easier to write robust and resource-efficient code.

In addition to managing unmanaged resources, IDisposable can also be used for other cleanup tasks, such as releasing managed resources like memory or event handlers. This flexibility allows developers to use IDisposable to implement a wide range of cleanup operations, making it a versatile interface for resource management in C#.

Stream Types in C#

FileStream

One common stream type is the FileStream, which is used for reading from and writing to files. It allows developers to work with data stored on disk and provides methods for reading and writing binary data efficiently. This type of stream is often used when dealing with files of various formats, such as text, images, or binary data.

using System;
using System.IO;

class Program
{
    static void Main()
    {
        string filePath = "sample.txt"; // Change this to the path of your text file.

        try
        {
            using (FileStream fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read))
            {
                using (StreamReader reader = new StreamReader(fileStream))
                {
                    string line;
                    while ((line = reader.ReadLine()) != null)
                    {
                        Console.WriteLine(line);
                    }
                }
                Console.ReadLine();
            }
        }
        catch (FileNotFoundException)
        {
            Console.WriteLine("The file does not exist.");
        }
        catch (IOException e)
        {
            Console.WriteLine($"An error occurred: {e.Message}");
        }
    }
}

NetworkStream

Another important stream type is the NetworkStream, which is used for communication over network connections, such as sockets. It enables the exchange of data between applications running on different machines. Network streams are essential for building networked applications, including client-server communication, where data must be sent and received across the network.

using System;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;

class Program
{
    static void Main()
    {
        int port = 12345; // Port to listen on

        // Start the server in a separate thread
        Thread serverThread = new Thread(() =>
        {
            StartServer(port);
        });
        serverThread.Start();

        // Give the server some time to start
        Thread.Sleep(1000);

        // Start the client
        StartClient("127.0.0.1", port);
    }

    static void StartServer(int port)
    {
        try
        {
            TcpListener listener = new TcpListener(IPAddress.Any, port);
            listener.Start();

            Console.WriteLine("Server is listening on port " + port);

            while (true)
            {
                TcpClient client = listener.AcceptTcpClient();
                Console.WriteLine("Client connected from " +   
                  ((IPEndPoint)client.Client.RemoteEndPoint).Address);

                using (NetworkStream stream = client.GetStream())
                {
                    byte[] buffer = new byte[1024];
                    int bytesRead;

                    while ((bytesRead = stream.Read(buffer, 0, buffer.Length)) > 0)
                    {
                        string message = Encoding.UTF8.GetString(buffer, 0, bytesRead);
                        Console.WriteLine("Received: " + message);

                        // Echo the message back to the client
                        byte[] responseBuffer = Encoding.UTF8.GetBytes("Server says: " + message);
                        stream.Write(responseBuffer, 0, responseBuffer.Length);
                    }
                }

                client.Close();
                Console.WriteLine("Client disconnected.");
            }
        }
        catch (Exception e)
        {
            Console.WriteLine("Server error: " + e.Message);
        }
    }

    static void StartClient(string serverIp, int serverPort)
    {
        try
        {
            TcpClient client = new TcpClient(serverIp, serverPort);

            using (NetworkStream stream = client.GetStream())
            {
                string messageToSend = "Hello, server!";
                byte[] data = Encoding.UTF8.GetBytes(messageToSend);

                stream.Write(data, 0, data.Length);
                Console.WriteLine("Message sent to server: " + messageToSend);

                byte[] buffer = new byte[1024];
                int bytesRead = stream.Read(buffer, 0, buffer.Length);
                string serverResponse = Encoding.UTF8.GetString(buffer, 0, bytesRead);
                Console.WriteLine("Server Response: " + serverResponse);
            }

            client.Close();
        }
        catch (Exception e)
        {
            Console.WriteLine("Client error: " + e.Message);
        }
    }
}

MemoryStream

For in-memory data manipulation, the MemoryStream type comes into play. It provides a way to work with data stored in memory as a stream, making it suitable for tasks like in-memory data transformations, data serialization, or creating temporary buffers for efficient data processing.

using System;
using System.IO;
using System.Text;

class Program
{
    static void Main()
    {
        // Create a MemoryStream for in-memory data manipulation
        using (MemoryStream memoryStream = new MemoryStream())
        {
            // Write data to the MemoryStream
            string message = "Hello, MemoryStream!";
            byte[] data = Encoding.UTF8.GetBytes(message);
            memoryStream.Write(data, 0, data.Length);
            Console.WriteLine("Write to MemoryStream: " + message);

            // Read data from the MemoryStream
            memoryStream.Seek(0, SeekOrigin.Begin); // Reset the position to the beginning
            byte[] buffer = new byte[1024];
            int bytesRead = memoryStream.Read(buffer, 0, buffer.Length);
            string readMessage = Encoding.UTF8.GetString(buffer, 0, bytesRead);

            Console.WriteLine("Read from MemoryStream: " + readMessage);
            Console.ReadLine();
        }
    }
}

StreamReader and StreamWriter

Text-based data can be efficiently handled with the StreamReader and StreamWriter classes, which are used to read and write text data from and to streams. These stream types are especially helpful when dealing with text files, parsing logs, or working with textual data over network connections.

using System;
using System.IO;

class Program
{
    static void Main()
    {
        string filePath = "sample.txt"; // Change this to the path of your text file.

        // Write to the text file using StreamWriter
        using (StreamWriter writer = new StreamWriter(filePath))
        {
            writer.WriteLine("Hello, StreamWriter!");
            writer.WriteLine("This is a simple example.");
        }

        // Read from the text file using StreamReader
        using (StreamReader reader = new StreamReader(filePath))
        {
            string line;
            while ((line = reader.ReadLine()) != null)
            {
                Console.WriteLine(line);
            }
        }
        Console.ReadLine();
    }
}

Serialization in C#

Serializing Data in C#

Serializing data in C# involves the process of converting complex data structures, such as objects or collections, into a format that can be easily stored, transmitted, or persisted. This transformation is crucial for tasks like data storage, data transfer over networks, or saving application state.

C# offers various serialization techniques, including XML, JSON, and custom serialization methods. These techniques allow data to be represented in a structured format that preserves its original state.

Note: Due to security vulnerabilities in BinaryFormatter, Serialize and Deserialize methods on BinaryFormatter, Formatter, and IFormatter are now obsolete as warning. Additionally, BinaryFormatter serialization is prohibited by default for ASP.NET apps.

Read more about BinaryFormatter issues here.

Serialization enables data to be efficiently stored in files, sent over the network, or stored in databases, making it a fundamental aspect of modern software development. Properly implemented serialization ensures data consistency, interoperability, and facilitates the seamless exchange of information between different systems and platforms, contributing to the overall robustness and flexibility of C# applications.

Using streams and Serialization in C#

Using streams and serialization in C# is a powerful combination that allows developers to efficiently work with data, store it, and exchange it between different applications or platforms.

Streams serve as conduits for data, facilitating reading and writing operations, while serialization is the process of converting complex data structures into a format that can be easily stored or transmitted.

When working with streams and serialization together, developers can serialize data into a structured format, such as XML or JSON, and then write it to a stream. This serialized data can be saved to a file or transmitted over a network.

Conversely, when reading data from a stream, it can be deserialized back into its original data structure, allowing developers to reconstruct objects or data collections.

Combining streams and serialization is especially useful when dealing with data persistence, like saving application settings, sharing data between different applications or services, or sending data between a client and a server in a networked application. It ensures that data can be easily and accurately transferred while maintaining its original structure and integrity.

However, it’s crucial to be cautious when using this combination, as improper handling of streams or serialization can lead to data corruption, security vulnerabilities, or compatibility issues. Developers should always consider factors like versioning, security, and performance when designing serialization processes involving streams to ensure robust and efficient data handling in C# applications.

Data Formats in C#

Data formats refer to the specific structures and conventions used to represent and organize data. C# supports various data formats, each tailored to different needs.

For instance, JSON (JavaScript Object Notation) is a widely used text-based format for data interchange due to its human-readable and lightweight nature. XML (Extensible Markup Language) is another format known for its extensibility and compatibility with various platforms and languages.

Choosing the right data format in C# depends on factors like data complexity, interoperability requirements, and performance constraints. The .NET framework offers libraries and APIs for parsing, creating, and manipulating data in these formats, allowing developers to work seamlessly with different data structures and ensuring data can be efficiently processed and exchanged within their applications and across systems.

Other Data Format in C#

CSV (Comma-Separated Values): CSV is a simple text-based format used for representing tabular data. It uses commas to separate values in each row. CSV files are commonly used for data exchange between applications and databases.

YAML (YAML Ain’t Markup Language): YAML is a human-readable data serialization format that uses indentation to represent structured data. It’s often used for configuration files and is known for its readability and simplicity.

INI (Initialization): INI files are plain text files that use a simple key-value pair structure to represent configuration data. They are widely used for storing configuration settings in applications.

Protocol Buffers (protobuf): Protocol Buffers is a binary serialization format developed by Google. It’s designed for efficiency and is commonly used in scenarios where fast and compact serialization is essential, such as in distributed systems.

MessagePack: MessagePack is another binary serialization format that is more compact and efficient than JSON or XML. It’s often used in high-performance scenarios where data needs to be serialized and deserialized rapidly.

Avro: Apache Avro is a binary data serialization framework commonly used in big data processing systems. It provides a schema for data, making it self-descriptive.

Toml (Tom’s Obvious, Minimal Language): TOML is a human-readable configuration file format designed for simplicity and ease of use. It’s used for configuration files in various applications and projects.

HTML (Hypertext Markup Language): While primarily used for structuring and presenting web content, HTML can also be used to structure and represent data, especially in web scraping and data extraction tasks.

GraphQL: GraphQL is a query language and runtime for APIs that allows clients to request only the data they need. While not a traditional data format, it’s used to define and query data in a flexible and efficient manner, often in web-based applications.

XML, or Extensible Markup Language, is a versatile and widely used data format in the world of computing. At its core, XML is a text-based markup language designed to represent structured data hierarchically. Each XML document consists of elements enclosed in tags, forming a tree-like structure that can represent a wide range of data types and their relationships.

XML

One of XML’s fundamental features is its flexibility. Users can define their own tags and structures, making it suitable for various data representation needs. This extensibility has contributed to XML’s popularity in diverse domains, from web development and configuration files to data exchange and storage.

XML documents typically adhere to a hierarchical structure, with one root element containing nested child elements. These elements can carry data, attributes, or both. Attributes provide additional information about an element and are written within its opening tag.

This hierarchical structure enables the representation of complex data relationships, making XML an ideal choice for describing data with parent-child associations or multiple levels of nesting.

XML’s readability and self-descriptive nature are also notable. Since XML is in plain text, humans can easily read and write it. Furthermore, XML documents can be accompanied by Document Type Definitions (DTD) or XML Schema Definitions (XSD) that define the structure and constraints of the data, providing a clear specification for how the data should be formatted and validated.

Despite its versatility and readability, XML has its drawbacks. XML documents can become verbose, especially when dealing with large datasets, which can impact transmission and storage efficiency. Consequently, alternative formats like JSON have gained popularity for their more compact representations.

Serializing and Deserializing XML

Serialization involves transforming objects or data structures into XML representation, typically for purposes like data storage or transmission. This process is often achieved using C#’s built-in XML serialization mechanisms, such as the XmlSerializer class, which allows developers to annotate classes with XML attributes to control the serialization process.

Deserialization, on the other hand, reverses the process by converting XML data back into objects or data structures. It is essential for reading and interpreting XML-based data from sources like files or network responses. By using the same XmlSerializer class or other XML parsing libraries, developers can recreate objects from XML, making it easy to work with structured data in C# applications.

XML Serialization Example

using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Xml.Serialization;

// Define classes to represent the XML structure
[XmlRoot("bookstore")]
public class Bookstore
{
    [XmlElement("book")]
    public List<Book> Books { get; set; }
}

public class Book
{
    [XmlElement("title")]
    public string Title { get; set; }

    [XmlElement("author")]
    public string Author { get; set; }

    [XmlElement("price")]
    public decimal Price { get; set; }
}

class Program
{
    static void Main()
    {
        // Create an instance of the Bookstore class
        Bookstore bookstore = new Bookstore
        {
            Books = new List<Book>
            {
                new Book { Title = "Introduction to XML", Author = "John Doe", Price = 29.95m },
                new Book { Title = "XML and C# Programming", Author = "Jane Smith", Price = 39.99m }
            }
        };

        // Serialize the data to XML
        XmlSerializer serializer = new XmlSerializer(typeof(Bookstore));
        using (TextWriter writer = new StreamWriter("books.xml"))
        {
            serializer.Serialize(writer, bookstore);
        }

        // Deserialize the data from XML
        using (TextReader reader = new StreamReader("books.xml"))
        {
            Bookstore deserializedBookstore = (Bookstore)serializer.Deserialize(reader);
            CultureInfo us = new CultureInfo("en-US");

            foreach (Book book in deserializedBookstore.Books)
            {
                Console.WriteLine("Title: " + book.Title);
                Console.WriteLine("Author: " + book.Author);
                Console.WriteLine("Price: " + book.Price.ToString("C2", us));
                Console.WriteLine();
            }
        }

        Console.ReadLine();
    }
}

Using XMLWriter in C#

An XMLWriter is part of the System.Xml namespace and provides a way to create valid XML documents by writing XML elements, attributes, and content to an output stream or file.To create an XMLWriter, you typically start by specifying the output destination, which can be a stream, a file, or even a text writer.

For example, you can create an XMLWriter to write XML content to a file using the XmlWriter.Create method, passing in a TextWriter or specifying a file path. This creates an XMLWriter instance that is ready to receive XML data.

Once you have an XMLWriter, you can use its methods to write elements, attributes, text content, and more to build your XML document. The writer automatically handles tasks like proper escaping of special characters and maintaining the XML structure.

One of the key advantages of using an XMLWriter is that it allows you to generate XML documents incrementally. You can start writing elements and attributes, nesting them as needed, and then close them when you’re finished. This makes it suitable for generating large and complex XML documents efficiently.

For example, you can use XMLWriter to create XML data for web services, configuration files, or data interchange formats. Additionally, XMLWriter can be used in conjunction with other XML-related classes in C# to read and manipulate XML documents, offering a comprehensive solution for working with XML data in various scenarios.

XMLWriter Example in C#

using System;
using System.Xml;

class Program
{
    static void Main()
    {
        // Create an XmlWriter to write XML to the console
        XmlWriterSettings settings = new XmlWriterSettings
        {
            Indent = true,  // Enable indentation for readability
        };

        settings.Encoding = new System.Text.UTF8Encoding(false);

        using (XmlWriter writer = XmlWriter.Create(Console.Out, settings))
        {
            // Write the XML declaration
            writer.WriteStartDocument();

            // Start the root element
            writer.WriteStartElement("fruits");

            // Write individual fruit elements
            WriteFruitElement(writer, "apple", "red");
            WriteFruitElement(writer, "banana", "yellow");
            WriteFruitElement(writer, "orange", "orange");

            // End the root element
            writer.WriteEndElement();

            // End the XML document
            writer.WriteEndDocument();
        }
        Console.ReadLine();
    }

    // Helper method to write a fruit element with a name and color
    static void WriteFruitElement(XmlWriter writer, string name, string color)
    {
        writer.WriteStartElement("fruit");
        writer.WriteElementString("name", name);
        writer.WriteElementString("color", color);
        writer.WriteEndElement();
    }
}
<?xml version="1.0" encoding="utf-8"?>
<fruits>
  <fruit>
    <name>apple</name>
    <color>red</color>
  </fruit>
  <fruit>
    <name>banana</name>
    <color>yellow</color>
  </fruit>
  <fruit>
    <name>orange</name>
    <color>orange</color>
  </fruit>
</fruits>

JSON

JSON, short for “JavaScript Object Notation,” is a widely used data interchange format. It serves as a lightweight and easy-to-read means of structuring and representing data. JSON utilizes a human-readable text format, making it accessible for both machines and humans.

Data in JSON is organized into key-value pairs, where keys are strings and values can be numbers, strings, arrays, or nested objects. This simplicity and flexibility make JSON a preferred choice for transmitting data between web servers and clients, as well as for configuration files and APIs.

Its compatibility with various programming languages and its simplicity in parsing and generating data have solidified JSON’s position as a fundamental tool in modern web development and data exchange.

Serializing and Deserializing JSON in C#

Serialization is the process of converting a C# object into a JSON representation. To serialize an object using JsonSerializer, you typically create an instance of this class and then call its Serialize method.

We can serialize and deserialize JSON using the JsonSerializer class, available in the System.Text.Json namespace. It’s important to note that the C# class structure should match the JSON data structure closely for successful deserialization. JsonSerializer uses attributes and naming conventions to map JSON properties to C# object properties, allowing for a smooth conversion process.

One advantage of JsonSerializer in C# is that it is part of the .NET Standard Library, making it available for use across different .NET platforms and applications. It’s a high-performance JSON library that offers good performance and memory efficiency, making it a suitable choice for many JSON serialization and deserialization tasks.

JSON Serialization Example in C#

//For Visual Studio 2019 add references to System.Text.Json and System.Memory,
//and System.Runtime.CompilerServices.Unsafe
using System;
using System.IO;
using System.Text.Json;

class Program
{
    static void Main()
    {
        // Create a C# object to serialize
        Person person = new Person
        {
            Name = "John Doe",
            Age = 30,
            City = "New York",
            Email = "johndoe@example.com"
        };

        // Serialize the object to a JSON string
        string jsonString = JsonSerializer.Serialize(person);

        // Write the JSON string to a file (set the file path)
        File.WriteAllText("person.json", jsonString);

        Console.WriteLine("Serialization complete. JSON file created.");

        // Deserialize the JSON file back into a C# object
        string jsonFromFile = File.ReadAllText("person.json");
        Person deserializedPerson = JsonSerializer.Deserialize<Person>(jsonFromFile);

        Console.WriteLine("\nDeserialization complete. Data from JSON file:");

        Console.WriteLine($"Name: {deserializedPerson.Name}");
        Console.WriteLine($"Age: {deserializedPerson.Age}");
        Console.WriteLine($"City: {deserializedPerson.City}");
        Console.WriteLine($"Email: {deserializedPerson.Email}");

        Console.ReadLine();
    }
}

class Person
{
    public string Name { get; set; }
    public int Age { get; set; }
    public string City { get; set; }
    public string Email { get; set; }
}

Custom Serialization in C#

Custom serialization in C# refers to the process of defining your own logic for converting objects into a serialized format. This approach allows developers to have fine-grained control over how their objects are serialized and deserialized, making it useful in scenarios where the default serialization behavior may not meet specific requirements.

By implementing custom serialization, developers can handle complex scenarios, such as versioning, data transformation, or encryption, tailored to their application’s needs. Custom serialization typically involves implementing interfaces like ISerializable or providing custom methods to control the serialization and deserialization process, offering flexibility and adaptability when working with diverse data formats or when dealing with non-standard object structures.

Custom Serialization Example in C#

using System;
using System.IO;

class Person
{
    public string Name { get; set; }
    public int Age { get; set; }

    public void Serialize(StreamWriter writer)
    {
        writer.WriteLine($"Name:{Name}");
        writer.WriteLine($"Age:{Age}");
    }

    public static Person Deserialize(StreamReader reader)
    {
        Person person = new Person();
        string line;
        while ((line = reader.ReadLine()) != null)
        {
            string[] parts = line.Split(':');
            if (parts.Length == 2)
            {
                string key = parts[0].Trim();
                string value = parts[1].Trim();

                switch (key)
                {
                    case "Name":
                        person.Name = value;
                        break;
                    case "Age":
                        if (int.TryParse(value, out int age))
                            person.Age = age;
                        break;
                    default:
                        // Handle unknown keys or ignore them
                        break;
                }
            }
        }
        return person;
    }
}

class Program
{
    static void Main()
    {
        // Create a Person object
        Person person = new Person
        {
            Name = "John Doe",
            Age = 30
        };

        // Serialize the Person object to a custom text file
        using (StreamWriter writer = new StreamWriter("person.txt"))
        {
            person.Serialize(writer);
        }

        Console.WriteLine("Serialization complete. Custom text file created.");

        // Deserialize the Person object from the custom text file
        using (StreamReader reader = new StreamReader("person.txt"))
        {
            Person deserializedPerson = Person.Deserialize(reader);

            Console.WriteLine("\nDeserialization complete. Deserialized Person object:");
            Console.WriteLine($"Name: {deserializedPerson.Name}");
            Console.WriteLine($"Age: {deserializedPerson.Age}");
        }
        Console.ReadLine();
    }
}

Using BitConverter in C#

BitConverter is a convenient way to convert between primitive data types and their binary representations. This class provides methods for converting values like integers, floats, and doubles to byte arrays and vice versa.

It is particularly useful when working with binary data, such as when dealing with network protocols or file formats that require precise byte-level manipulation. BitConverter simplifies these conversions by abstracting the complexities of managing byte order (endianness) and data type sizes, ensuring platform-independent compatibility.

Developers can easily convert data between its native format and a byte array for serialization or deserialization tasks. Overall, BitConverter serves as a valuable tool for low-level data manipulation in C# applications.

BitConverter Example

using System;

class Program
{
    static void Main()
    {
        // Create a simple data structure
        int intValue = 42;
        double doubleValue = 3.14159;

        // Serialize the data to a byte array
        byte[] serializedData = SerializeData(intValue, doubleValue);

        // Deserialize the data from the byte array
        DeserializeData(serializedData, out int deserializedIntValue, out double deserializedDoubleValue);

        // Display the deserialized data
        Console.WriteLine($"Deserialized Integer: {deserializedIntValue}");
        Console.WriteLine($"Deserialized Double: {deserializedDoubleValue}");
        Console.ReadLine();
    }

    static byte[] SerializeData(int intValue, double doubleValue)
    {
        byte[] intBytes = BitConverter.GetBytes(intValue);
        byte[] doubleBytes = BitConverter.GetBytes(doubleValue);

        byte[] serializedData = new byte[intBytes.Length + doubleBytes.Length];
        Array.Copy(intBytes, serializedData, intBytes.Length);
        Array.Copy(doubleBytes, 0, serializedData, intBytes.Length, doubleBytes.Length);

        return serializedData;
    }

    static void DeserializeData(byte[] serializedData, out int intValue, out double doubleValue)
    {
        intValue = BitConverter.ToInt32(serializedData, 0);
        doubleValue = BitConverter.ToDouble(serializedData, sizeof(int));
    }
}

Read more about serialization in Microsoft Learn.