Laboratory Training 2
Creation and Use of Classes
1 Training Tasks
1.1 Individual Task
Create a C# console application that implements classes according to the task. The classes listed in the table must correspond to the topic chosen in the first laboratory training.
Group of Entities | Entity | Additional Class |
---|---|---|
Group | Student | Address |
Examinations | Subject | Lecturer |
City | Ward | Country |
Region | City | Country |
Sports group | Member | Address |
Football team | Player | Country |
Music group | Singer | Country |
Music group | Album | Country |
Album | Song | Author of Lyrics |
Flat | Room | Address |
Storybook | Story | Author |
Artist | Masterpiece | Country |
Subway | Station | City |
Railway section | Station | Country |
Writer | Novel | Country |
If the list of topics was expanded in the previous laboratory training, the corresponding classes should be agreed with the teacher.
An entity group must contain an array of specific entities.
Separate helper classes should be created for formatted output of data to the console. In general, the structure of classes must meet the requirements of the Single Responsibility Principle.
The additional class type field must be located in one of the classes (entity groups or entities) depending on the task variant. In a separate class, provide methods for searching and sorting data, defined in the individual task of the previous laboratory work. Classes must contain public properties. XML comments should be added to classes and public methods.
The data to be searched will be entered from the keyboard at runtime. The test data must be prepared in such a way that the search gives more than one result.
1.2 Solving a Quadratic Equation
Create three variants of the function for solving a quadratic equation:
- The first function should receive as parameters the coefficients of the equation, as well as the roots as parameters
with the
out
attribute. The function should return the number of roots and -1 if the number of roots is infinite. - The second function should receive as parameters the coefficients of the equation and return a structure that stores three values: the number of roots, the first and second roots.
- The third function should receive as parameters the coefficients of the equation and return a tuple consisting of three values: the number of roots, the first and second roots.
Demonstrate the working of all functions.
1.3 Creation of Class "Complex"
Create a class Complex
(complex number), overload the following operations: +
, -
, *
, /
,
and implicit cast to string
type. Use object initializers for creation test objects
1.4 Expansion of String Class
Extend the standard System.String
class with method of removing extra spaces (multiple spaces should
be replaced with single space character).
1.5 3D Point
Implement structure (struct
) to represent a point in three-dimensional space. Implement a function
calculating the distance from the point to the origin. Test structure in a separate class.
1.6 Class for Representation of a Quadratic Equation (Advanced Task)
Create a class "Quadratic", whose roots can be obtained as read-only properties of nullable type. Add indexer to access the roots by index.
2 Instructions
2.1 Namespaces
Namespaces provide means for logical grouping of classes and other namespaces. The syntax and usage of namespaces is very close to one in C++. Here is an example of namespace definition:
namespace NewSpace { public class SomeClass { public void f() { } } public struct SomeStruct { } }
Namespace can be extended to other files. If you don't declare any namespace, default (global) namespace assumed.
As can be seen from the example, the namespace groups classes (and other types). Namespaces can be invested in each other. Instead of creating multiple nested spaces, you can describe namespaces with dots. For example, instead of such a description
namespace First { namespace Second { namespace Third { } } }
can be applied more compact:
namespace First.Second.Third { }
Version C # 10 allows you to determine the namespace to the end of the file without unnecessary braces (file-scoped namespace declaration). If you specify the name of the namespace with semicolon at the beginning of the file, this means that all the definitions to the end of the file refer to this namespace:
namespace SomeSpace; // // The entire code is defined in the SomeSpace namespace //
To access particular elements, dot character is used. If you want to use several (or all of) the members of a
namespace, C# provides an easy way to get access to the complete namespace. The using
directive specifies that all identifiers in a namespace are in scope at the point that the
using
-directive statement is made. For example:
using NewSpace; namespace OtherSpace { public class Test { public void g() { SomeClass sc = new SomeClass(); // type name without prefix sc.f(); } } }
You can use an alternate name (alias) to refer to a namespace identifier:
using S = System; ... S.Console.WriteLine();
As seen from this example, the elements of a namespace can be accessed from alias using dot character. But
there is a more appropriate use of ::
operator. In this case, it is clear that we work with alias:
using S = System; ... S::Console.WriteLine();
C# provides a context-sensitive keyword global
(alias to global namespace). For example:
global::System.Console.WriteLine();
This alias is used to prevent name conflicts.
In the version 10 of C#, the capabilities of using
directive are extended. In
particular, you can add a global
modifier to any
using
directive. This directive will apply to all source files within the project.
2.2 Classes. Encapsulation. Properties
2.2.1 Class Definition. Encapsulation
All C# code can be placed only inside of classes, structures, or interfaces. Here is an example of a simple class:
class Rectangle { public double Width; public double Height; public double Area() { return Width * Height; } }
Unlike C++, non-abstract and non-partial methods are always implemented inside a class (a structure) body.
In addition to fields width
and height
, the class contains a method Area()
for
calculating the area of a rectangle. The product of the fields is calculated in the body of the method. The values
stored in the class fields are different for different objects. We call the method for a specific object with
defined values
width
and height
. These fields can be used directly, or by referencing the current object
via a special this
reference:
public double Area() { return this.width * this.height; }
The usage of this
is similar in many respects to the corresponding pointer in C++. It is usually
explicitly used to prevent name conflicts. In the above example, its use is not advisable.
The disadvantage of the previous example is the presence of public fields. This is a violation of the principle of encapsulation. As most object-oriented programming languages, the C# language supports different levels of access to members:
private
: members are available only within the class; this is default level, members without any modifier have private access;protected
: members are available in this class and all derived classes;public
: members are available from any project code (if the class is public);internal
: members are available from any code within the assembly;protected internal
: members are available from any code within the assembly, as well as in all derived classes (if the class is public);private protected
provides access for this class and derived classes declared in the same assembly only.
Unlike C++, C# specification requires a separate access declaration for each class member (or groups of data members defined after a single type specifier) without colon.
The class itself can be declared as public
, otherwise it will only be available within the
assembly (you can also explicitly add the internal
modifier).
A class can be defined in a namespace or in another class.
Fields should be defined as private. When you create an object, fields are initialized with default values. You can also initialize fields explicitly:
public class Rectangle { private double width = 10; private double height = 20; public double Area() { return width * height; } }
Classes in C# are always reference types. You can first define a reference to the object:
Rectangle rectangle;
Such a reference would be undefined, which is not desirable. Traditionally, to indicate that the reference does
not refer to any object, a constant null
is used:
Rectangle rectangle = null;
In modern versions of C#, the usage of null
is considered undesirable because attempting
to apply any action to the object results in a NullReferenceException
being thrown. In addition, the
origin of a null
value is sometimes difficult to trace. This result can occur for
various reasons. If you still want to use null
, you should specify that the nullable
reference type. Starting with C# 8.0, reference types also can be nullable:
Rectangle? rectangle = null;
To create an object, you must first define a reference, and then write the address of a new object located in free store into it:
rectangle = new Rectangle();
In our case, it makes no sense to use nullable type. In addition, you can combine the definition of the
reference and the creation of the object in one statement. You can also not repeat the class name after
new
. You can call the Area()
method for the new object:
Rectangle rectangle = new(); Console.WriteLine(rectangle.Area());
The Rectangle
class cannot yet be used to represent different rectangles, since the values width
and height
cannot be changed or even read. For each object of Rectangle
type, the method Area()
will
always return 200. In addition to hiding data, encapsulation also provides a mechanism for controlled reading and
modification of fields. The traditional approach, implemented in almost all object-oriented languages, consists
in the creation of special access methods – getters and setters, which provide reading and writing of data
with the ability to control the correctness of access. In a simpler case, correctness may not be checked:
public class Rectangle { private double width = 10; private double height = 20; public double GetWidth() { return width; } public void SetWidth(double width) { this.width = width; } public double GetHeight() { return height; } public void SetHeight(double height) { this.height = height; } public double Area() { return width * height; } }
Note: since it is desirable to use setter parameter names that match the corresponding
fields, name conflicts occur, so it is advisable to use this
before the field names.
Now you can read and change the values of the fields:
Rectangle rectangle = new(); rectangle.SetWidth(20); rectangle.SetHeight(30); Console.WriteLine(rectangle.GetWidth() + " " + rectangle.GetHeight()); Console.WriteLine(rectangle.Area()); // 600
2.2.2 Properties
To implement encapsulation, instead of access functions, the C# language offers an approach based on the use of so-called properties.
Properties are class elements, which look just like fields, but from the inside, properties contain code that should be executed by getting and setting values concerned with properties. In most cases, properties represent actual class fields. Therefore, properties can be used instead of C++'s setters and getters. Sometimes properties can also be unrelated to fields.
To define property, you should set property type, followed by property type (by convention, property name should
start from capitalized letter), followed by programming block. Within this block, you should create two subblocks
started with set
and get
keywords. The set
{}
block
contains code that should be executed by setting a new value to property. You can use value
keyword
which represents value assigned to property. The get
{}
block
is executed if property is used for reading. This block must contain return
statement.
You can create read-only properties (without set
{}
block) and write-only properties
(without get
{}
block).
In the Rectangle
class, instead of getters and setters, we can add properties and thus implement encapsulation.
In addition, the calculation of the area can be implemented not as a method, but as a read-only property:
public class Rectangle { private double width = 10; private double height = 20; public double Width { get { return width; } set { width = value; } } public double Height { get { return height; } set { height = value; } } public double Area { get { return width * height; } } }
Now we can simplify the work with the class object:
Rectangle rectangle = new(); rectangle.Width = 20; rectangle.Height = 30; Console.WriteLine(rectangle.Width + " " + rectangle.Height); Console.WriteLine(rectangle.Area);
By convention, property names begin with an uppercase letter.
In recent versions of C# (since 7.0), lambda expression syntax can be used inside a property description, as long as the corresponding program block can be implemented as a single expression. For example:
public class Rectangle { private double width = 10; private double height = 20; public double Width { get => width; set => width = value; } public double Height { get => height; set => height = value; } public double Area { get => width * height; } }
Version C# 3.0 (2007) provides so-called automatic properties:
public int X { get; set; }
Each such property concerned with automatically created private field which is invisible and cannot be accessed neither from inside nor from outside a class. The access can be made through the property only. When you create an object, fields related to automatic properties are assigned the default values for their types. C# version 6.0 also allows you to initialize automatic properties.
Since in our case no additional code for properties is needed, we can create automatic properties Width
and Height
.
Now we don't need explicit fields width
and height
because other hidden fields are automatically
created. In the calculation of the area, the fields should also be replaced with properties:
public class Rectangle { public double Width { get; set; } = 10; public double Height { get; set; } = 20; public double Area { get => Width * Height; } }
The code of the application that uses the properties has not changed.
The following advantages of using automatic properties compared to public fields can be noted:
- if necessary, you can change the code of the class and apply the usual properties with the necessary code correctness control without changing the code that uses the class and its properties;
- you can define directives for writing and reading separately.
For example, the property described below can be read from anywhere in the program, but modified only inside the class:
public int X { get; private set; }
A similar option has also been added for regular (non-automatic) properties, for example:
private int x; public int X { get { return x; } protected set { x = value; } }
2.2.3 Initialization of Objects
You can create an instance by applying new
operator to constructor:
Rectangle rectangle = new Rectangle();
A new
operator created an object in free store (heap). In previous example, rectangle
is
a name of a reference to an object. The name Rectangle
after new
is the name of
the constructor, which must match the name of the class.
A constructor is a function A constructor is a method that initializes an object. A class may define several constructors. If no constructor is defined by the class, a default constructor (without arguments) is automatically created by constructor. Default constructor initializes all the fields to their default values.
A constructor or multiple constructors can be defined explicitly. You cannot specify constructor result type. If more than one constructor is defined in a class, they must have different parameter lists. After at least one explicit constructor is defined, the default constructor is not automatically created.
You can invoke constructor with arguments from another constructor using this
keyword. Such
invocation is placed into constructor's initialization list between constructor's header and constructor's body. In
addition, using this
keyword avoids name conflicts:
public class Rectangle { double width; double height; public Rectangle(double width, double height) { this.width = width; this.height = height; } public Rectangle() : this(10, 20) // invocation of another constructor { } // ... }
When using properties, there is usually no name conflict. In addition, you can, for example, add a constructor with one parameter:
public class Rectangle { public double Width { get; set; } public double Height { get; set; } public Rectangle(double width, double height) { Width = width; Height = height; } public Rectangle(double width) { Width = width; } public Rectangle() : this(10, 20) { } // ... }
You can use the syntax of lambda expressions to define constructors. This is appropriate in the case when the body of the constructor consists of one statement:
public Rectangle(double width) => Width = width;
In addition to initialization using constructors, C# offers an alternative initialization mechanism: so-called initializers.
Traditionally languages of object-oriented programming provide constructors with parameters to initialize fields with necessary values. However, sometimes set of constructors do not meet our requirements, so we need to set values of some properties manually. Using C# 3.0 (and later), you can initialize public properties and public fields in a special block placed after constructor invocation. Expressions of initialization are listed separated by commas.
There are two syntax forms of initializers:
Rectangle rectangle1 = new() // or new Rectangle() { Width = 30, Height = 40 }; Rectangle rectangle2 = new Rectangle { Width = 30, Height = 40 };
In these examples, it first calls the constructor with no parameters, and then the specified values are put into properties.
You can combine the use of parameterized constructors with initializers. For example, you can define a value Width
through a constructor with one parameter, and Height
through
an initializer:
Rectangle rectangle3 = new Rectangle(50) { Height = 60 };
You can try to initialize the object with the constructor and the initializer at the same time, although this does not make sense:
Rectangle rectangle4 = new Rectangle(50, 60) { Width = 70, Height = 80 };
Since the initialization block is executed after constructor, the values assigned in the constructor will be overwritten by the values specified in the block.
Sometimes there is a need for nested initializers. Suppose we created a class to represent a point in the Cartesian coordinate system:
public class Point { public double X { get; set; } public double Y { get; set; } public Point() { } public Point(double x, double y) { X = x; Y = y; } }
Suppose a class Triangle
contains a definition of properties of the type Point
:
public class Triangle { public Point A { get; set; } = new(); public Point B { get; set; } = new(); public Point C { get; set; } = new(); }
Now you can call the constructors inside the initialization block:
Triangle triangle = new() { A = new Point(0, 0), B = new Point(0, 4), C = new Point(3, 0) };
The following nested initialization can also be suggested:
Triangle triangle = new() { A = new() { X = 0, Y = 0 }, B = new() { X = 0, Y = 4 }, C = new() { X = 3, Y = 0 } };
C# supports destructors. The syntax of their description is quite similar to C++. You can also use lambda expressions.
class Rectangle { //... ~Rectangle() // destructor { } }
There can only be one destructor because it never has parameters.
Destructor is always invoked by garbage collector by destruction of object. Sometimes garbage collector does not remove objects. Therefore, destructor will not be invoked.
2.2.4 Static Class Members. Constants
Fields, methods and properties can be defined with static
keyword. You can access these elements
without creation of object. You can initialize static fields inside a class body.
For example, you can add a static field count
to the Rectangle
class, which will store
the number of objects of type Rectangle
that were created in different places of the program. In the
constructor, the value of this field is increased by one. In order to prevent accidental spoiling of the counter
value, the field should be defined as private and an appropriate access method should be added:
public class Rectangle { private static int count = 0; public static int GetCount() { return count; } public Rectangle() => count++; public double Width { get; set; } public double Height { get; set; } }
Note: the Area
property will be omitted for brevity in the class code.
We can use the counter as follows:
// There was no Rectangle object yet Console.WriteLine(Rectangle.GetCount()); // 0 Rectangle rectangle = new(); Console.WriteLine(Rectangle.GetCount()); // 1
We can add a destructor to the class, in which the number of objects will decrease:
~Rectangle() => count--;
But, as mentioned earlier, the call of destructors is not guaranteed. Most likely, during the operation of a small program, the garbage collector will not work at all and the destructor will not be called.
There are static properties in C#. Instead of an access method, we can create a read only property:
public static int Count { get => count; }
The property can be defined as automatic with the ability to write only inside the class. This property will be used everywhere instead of a field:
public class Rectangle { public static int Count { get; private set; } public Rectangle() => Count++; ~Rectangle() => Count--; public double Width { get; set; } public double Height { get; set; } }
The use of the counter is similar:
// There was no Rectangle object yet Console.WriteLine(Rectangle.Count); // 0 Rectangle rectangle = new(); Console.WriteLine(Rectangle.Count); // 1
Static member functions and properties do not obtain this
reference.
C# allows access to static elements only through the class name. Unlike C++, where you can create an object or pointer to simplify working with static elements, in C#, referring to static elements via an object reference name results in a compile error:
Rectangle r; Console.WriteLine(r.Count); // compile error
You can create so-called static constructors for initialization of static members. The definition of a static constructor
is started with static
keyword. You cannot define visibility for static constructors. You cannot
access non-static class elements from static constructor.
Static constructor is always invoked implicitly before any use of a class. For example, the counter can be initialized in a static constructor:
public class Rectangle { public static int Count { get; private set; } static Rectangle() => Count = 0; // ... }
You can create both static and non-static constructors with the same argument lists within a single class.
The version 2.0 of C# language introduces concept of static classes. Static classes can define static members only.
public static class Static { private static int k = 144; public static int GetK() { return k; } }
Static classes cannot be used for creation of objects, as well as creation of derived classes.Static class can have a static constructor.
You can create constants inside a class body (a field with const
modifier). Such constants
are static by default. These constants are created by the compiler and cannot be changed anywhere else. Such constants
are static by default:
public class PhysicsConstants { public const double Gamma = 6.67430E-11; // Gravitational constant public const double SpeedOfLight = 299792458; // Speed of light //there may be other members }
Using constants is similar to working with other static members, for example:
Console.WriteLine(PhysicsConstants.Gamma);
The readonly
modifier allows you to create non-static constants. The value of such constant
can be set only by definition or in constructors. Different instances of a class can store different values of readonly
fields:
public class PhysicsConstants { public const double Gamma = 6.67430E-11; // Gravitational constant public const double SpeedOfLight = 299792458; // Speed of light public readonly double G; // Gravitational acceleration public PhysicsConstants(double g) => G = g; //there may be other members }
For different planets, you can determine gravitational acceleration value:
PhysicsConstants EarthConstants = new(9.81); // g on Earth PhysicsConstants MoonConstants = new(1.62); // g on the Moon
Starting with C# version 6.0, you can create automatic read-only properties:
public int X { get; }
Such properties can be set only in the body of a constructor (like a field with readonly
modifier).
Trying to set such property in another method generates a compiling error.
2.2.5 Representation of Real Entities by Classes. Applying the SRP Principle
Object-oriented decomposition involves the representation of real entities by classes in the software model. The description of such classes involves the presentation of various aspects of the life cycle and interaction with other objects. But very often classes that represent a specific entity of the real world turn out to be very large, because they try to combine all the duties of the entity and all aspects of interaction with the external environment.
The Single Responsibility Principle of is one of the five principles of SOLID. It states that each class or module should be responsible for only one task or aspect of the system and have only one reason for change. Instead of combining several aspects of behavior in one class, the representation of some entity should be separated into separate classes. Each class is responsible for one aspect or function of the program. Adherence to this principle has the following advantages:
- Each class or module performs one specific task, making them easier to understand, test, and modify.
- If you need to change the logic of certain functionality, the changes will happen in one place, which reduces the possibility of errors in other parts of the program.
The practical application of the principle of single responsibility is considered in example 3.3.
2.3 Definition of Methods
Method is a function that is defined within the class and has direct access to other class members. Unlike C++, methods are always implemented within class body.
To invoke public static method of another class, you should use the following syntax:
Class_name.Method_name(actual_parameters)
In such form we invoke standard mathematical routines. For example
y = Math.Sin(x);
Starting from C# 6.0, you can import static members from particular class, for example:
using static System.Math;
Now you can invoke static methods defined in the Math
class without qualifying those methods as
members of that class. You must use the fully qualified class name in the using
directive.
Now you can use the following syntax:
y = Sin(x);
Some methods do not need parameters:
static int Zero() { return 0; }
The call of such function also requires empty brackets:
Console.WriteLine(Zero());
The return
statement within function body causes termination of function's execution. The
expression after return
keyword defines a value, which will be return from this function.
Sometimes, function returns no value. To declare such function, void
keyword is used as
resulting type.
static void Hello() { Console.WriteLine("Hello!"); }
In this case, you can omit return
keyword in function's body. The
return
statement (without succeeding expression) allows termination of function before end
of body. Function with void
type can be called using separate statement only.
Hello();
Function also can be called from the body of the same function. Recursion is a mechanism of calling function from itself directly or indirectly.
By default, arguments of value types are transferred by value. Reference typed arguments are transferred by
reference. Sometimes you need to transfer value-typed arguments by reference. There are two ways of definition
such arguments. If you want to change old values of variables, you can define arguments with ref
keyword.
public static void Swap(ref int a, ref int b) { int c = a; a = b; b = c; }
At the point of invocation, you must prefix names of actual parameters with ref
keyword.
Variables those are used as actual arguments must be initialized first. Otherwise, compiler produces an error.
using System; namespace SwapperApp { class Swapper { public static void Swap(ref int a, ref int b) { int c = a; a = b; b = c; } static void Main(string[] args) { int x = 1; int y = 10; Swap(ref x, ref y); Console.WriteLine("x = " + x + " y = " + y); } } }
Starting with C# 7.0, you can also use the ref
modifier before function's
returning type. In this case, you also need to add the ref
modifier at function
invocation:
static ref int SecondName(ref int n) { return ref n; } static void Main(string[] args) { int k = 3; ref int k2 = ref SecondName(ref k); k2 = 4; Console.WriteLine(k); }
Another need in transferring arguments by reference is getting several results from some function. The
out
keyword is used to specify the transfer of parameters by the reference. The compiler
checks whether such parameters are assigned values in the body of the function.
The following function takes an array of floating-point numbers, returns the minimum value, and returns
the index of the item with the minimum value via parameter with an out
attribute:
static double? Min(double[] arr, out int index) { if (arr == null || arr.Length< 1) { index = -1; return null; } index = 0; for (int i = 1; i < arr.Length; i++) { if (arr[i] < arr[index]) { index = i; } } return arr[index]; }
You must also specify the word out
for the actual parameters when calling the function:
double[] a = { 3, 4, 1, 2.5 }; int index; Console.WriteLine(Min(a, out index) + " " + index);
Version 7.0 of C# allows definition of necessary variables with out
modifiers
inside the method invocation within the list of actual arguments. After returning from an invoked method you can
use them:
... static void Main(string[] args) { double b = 3; SolveEquation(b, out double x1, out double x2); Console.WriteLine("x1 = " + x1 + " x2 = " + x2); } } }
Starting from C# 7.2 you can use in
modifier by sending argument by reference.
Placing in
keyword before value-typed parameter allows declaration of a parameter
that you transfer by reference, but its value cannot be modified; it treated as some constant within a
function's body. For example:
static double Sum(in double x, in double y) { return x + y; } static void Main(string[] args) { double x = 1, y = 2; Console.WriteLine(Sum(in x, in y)); // the in modifier is obligatory }
If you'll try to modify values of such parameters in a function body, you'll get a compiler error:
static double Sum(in double x, in double y) { x = 4; // Error! // ... }
The reason in usage the modifier is efficiency of sending large data structures without copying.
Note: the in
modifier works like reference to constant in C++.
Version 4.0 of C# allows description of the function with so-called default parameters. This approach allows you to call a function with different number of parameters. For example:
static double Sum(double a, double b = 0, double c = 0) { return a + b + c; }
This function can be called by sending either one, or two, or three parameters:
double x = 0.1; double y = 0.2; double z = 0.3; Console.WriteLine(Sum(x)); Console.WriteLine(Sum(x, y)); Console.WriteLine(Sum(x, y, z));
If the default values satisfy our needs, corresponding actual parameters may be omitted. Default parameters should be the last in argument list, otherwise we get a syntax error message:
static void F(double x, int y = 0, int h) { } // Syntax error!
Starting with C# 4.0, you can specify names of the formal parameters by invocation of functions. The following fragment program calculated the expression y = ax + b:
static double Y(double a, double x, double b) { return a * x + b; } static void Main(string[] args) { Console.WriteLine(Y(a: 2, x: 3, b: 4)); // 10 }
This feature is especially useful in combination with default parameters. It allows you to specify only the required parameters. For example:
static double Y(double a = 1, double x = 0, double b = 0) { return a * x + b; } static void Main(string[] args) { Console.WriteLine(Y(a: 2, x: 3, b: 4)); // 10 Console.WriteLine(Y(x: 5, b:11)); // 16 Console.WriteLine(Y(x: 5)); // 5 Console.WriteLine(Y()); // 0 }
Starting from C# 7.0, you can create local functions. Now methods can be created inside the context of another
method. The local method can be only called from the context in which is it declared. In C# 8.0 local functions
can also be static. Such functions cannot have access to local variables and parameters. In the following
example, the sum()
local function cannot be static because it uses value of n
(local
argument). The cube()
function is static because it gets all necessary information from its
arguments list:
static int SumOfCubes(int n) { return sum(); // local function: int sum() { int result = 0; for (int k = 1; k <= n; k++) { result += cube(k); } return result; } // static local function: static int cube(int k) { return k * k * k; } }
As you can see, names of local functions can start with small letters.
All non-static methods have implicit reference to the object for which they are used.
2.4 Operator Overloading
C# supports overloading for some operators:
- Unary operators:
+
,-
,!
,~
,++
,--
,true
,false
- Binary operators:
+
,-
,*
,/
,%
,&
,|
,^
,<<
,>>
,==
,!=
,<
,>
,<=
,>=
Overloading of true
and false
operators allows use references to object
in conditional operation, if
statements, and cycle headers. Some operators, namely true
and false
, ==
and !=
, >
and <
, >=
and <=
,
are pairs: if you decide to overload one of pair operators you must overload also second operator of this pair.
To overload operator, you should create public static method which name is constructed from operator
keyword
and corresponding operator.
The explicit operator
Type_name()
and implicit operator
Type_name()
are
used for explicit and implicit type conversion. Implicit conversions in C# correspond to special conversion operators
(conversion from a class to another type) and to constructors with one parameter (conversion from another type to
this class).
The following class provides overloading of "plus" operator and implicit conversion to string
type:
using System; namespace PointTest { class Point { private double x, y; public Point(double x, double y) { this.x = x; this.y = y; } public static Point operator+(Point a, Point b) { return new Point(a.x + b.x, a.y + b.y); } public static implicit operator string(Point p) { return p.x + " " + p.y; } } class Test { static void Main(string[] args) { Point p1 = new Point(1, 2); Point p2 = new Point(3, 4); Point p3 = p1 + p2; Console.WriteLine(p3); // cast to string } } }
If you overload some arithmetic operator (e.g. "+"), appropriate self-assigned operator is automatically
overloaded (e.g. "+=
").
2.5 Indexers
A special type of properties, so-called indexer, allows applying index operator ([]
) to object name.
To create an indexer, you should define resulting type, followed by this
keyword,
followed by definition of index in square brackets. The rest of definition is the same as one in property definition.
In the class below, the indexer provides access to a private field:
class HiddenArray { private int[] arr = { 1, 2, 3 }; public int this[int index] { get { return arr[index]; } set { arr[index] = value; } } }
Thanks to the indexer it is possible to work with object of the created class as with an array:
HiddenArray hiddenArray = new(); hiddenArray[0] = 4; int k = hiddenArray[1];
The following class represents 3D point and provides indexer for handle with particular dimensions (1, 2, or 3):
using System; namespace Point3DApp { class Point3D { double x, y, z; public Point3D(double x, double y, double z) { this.x = x; this.y = y; this.z = z; } public double this [int index] // indexer { set { switch (index) { case 1: x = value; break; case 2: y = value; break; case 3: z = value; break; } } get { switch (index) { case 1: return x; case 2: return y; case 3: return z; // otherwise return maximal double value default: return Double.MaxValue; } } } } class Test { static void Main(string[] args) { Point3D p3d = new Point3D(2, 3, 4); p3d[3] = 5; // writing for (int i = 1; i <= 4; i++) { Console.WriteLine(p3d[i]);// reading } } } }
You can use non-integer index types.
2.6 Anonymous Types and Records
Starting with version C# 3, you can create local variables of so-called anonymous types. For example:
var city = new { Name = "Kyiv", Population = 2884000 }; Console.WriteLine(city.Name);
An anonymous type is a reference type. The values that were defined when the variable was created are read-only
and cannot be changed. Methods, events, and elements other than properties cannot be added to anonymous types. For
anonymous types, the Equals()
method is defined, which checks the equivalence of all properties.
In order to pass an object of an anonymous type to a function as an argument, the parameter must be described as object
.
var city = new { Name = "Kyiv", Population = 2884000 }; PrintCity(city); . . . void PrintCity(object city) { Console.WriteLine(city); // { Name = Kyiv, Population = 2884000 } }
The possibilities of using such parameters are significantly limited, because the variable cannot be explicitly converted to an anonymous type. Therefore, it is desirable to use variables of anonymous types in the same block where they are defined.
The record type introduced in version C# 9 is a simplified class, in many respects similar to anonymous types. It is also usually created to represent immutable objects. For example, you can describe the following record:
public record City { public string Name { get; init; } public int Population { get; init; } }
The init
keyword indicates that the automatic property can be assigned in the constructor
or in the initializer, but then its value cannot be changed. Now we have a type for immutable objects.
Note: The init
keyword in the description of automatic properties, introduced
in C# 9, can be used not only to describe properties of records, but also in regular classes.
You can now create record type objects.
var city = new City() { Name = "Kyiv", Population = 2884000 };
Records support inheritance. You can create constructors. Automatic equivalence check is supported.
2.7 Structures and Enumerations
Structure in C# looks like class. Structure can contain fields and methods, but it is not a reference type.
Structures are always value types. Structures are suitable to represent small objects. In addition, the structure
is always created in the programming stack, even if it was created using new
operator. Placing
within the stack increases the effectiveness of the program. Structure can contain constructors. Automatically created
parameterless constructor initializes all fields by default values for the corresponding types.
public struct Point { public double x, y; public Point(double x, double y) { this.x = x; this.y = y; } }
Previous versions of C# (until version C# 9) you could not initialize data in the body structure. It was not possible to redefine default constructor. Version C# 10 allows you to define constructor without parameters that provides field initialization with the required values.
You cannot assign null
value to structures (if they are non-nullable). Garbage collector does
not remove struct
objects from memory.
You can create objects of struct
type using new
operator. This guarantee
assignment of default values to fields. Otherwise, fields are not initialized and their values cannot be used without
assignment:
Point p1 = new Point(); double d1 = p1.x; // d1 == 0 Point p2; double d2 = p2.y; // Compile error
Initialization and assignment operations effect full copying of field values:
Point p1 = new Point(); Point p2 = p1; // copying
Structures are transferred to functions by value. To transfer them by reference ref
and out
modifiers
are used.
Because structures are value types, when they are used where a reference to a class object is expected (for example,
in assigning them to an object of type System.Object
), their so-called boxing into a reference type
occurs. The memory in the free store is allocated, to which all the data are copied, and a reference to this area
is returned. During the inverse conversion, "unboxing" takes place: the data is copied to an instance
of the structure:
Point p = new Point(1,2); object ob = p; // boxing, make a copy Point p2 = (Point) ob; // unboxing
Starting with version C# 10, record objects can also be allocated in the stack. To describe such records pair of
words record struct
is used.
Enumeration in C# is a type for representation of a set of named constants. For example:
enum Digits { zero, one, two, three } // zero == 0, one == 1, two == 2, three == 3
By default, first constant is 0, and other are defined by adding 1 to previous one. You can set some values explicitly. The values of followed constants are defined using the same rule:
enum Digits { one = 1, two, three } // two == 2, three == 3
You can use enumeration elements for definition of other elements:
enum Digits { one = 1, two, min = one, max = two }
To access elements of enumeration, type name followed by constant name. For example: Digits.two
. You
can declare variables and constants of enumeration type:
Digits d = Digits.one;
To obtain integer value of an element of enumeration, explicit type cast is needed:
int c = (int) Digits.two; // c == 2
The default type of constants that can be stored in enumerations is int
. However, this type
(base type of enumeration) can be changed. For example:
enum Digits : byte { one = 1, two, three } // two == 2, three == 3
To work with enumerations you can use static methods of System.Enum
type. In particular, the method GetUnderlyingType()
returns
a data type that is used to store the values of enumeration, GetValues()
returns an array of values.
For example:
enum Digits : byte { one = 1, two, three } class Program { static void Main(string[] args) { Console.WriteLine(Enum.GetUnderlyingType(typeof(Digits))); foreach (var v in Enum.GetValues(typeof(Digits))) { Console.Write(v + " "); // one two three } } }
In this example, typeof
operator returns an object of System.Type
. Its overloaded ToString()
method
allows you to get type name.
2.8 Tuples
Starting from C# 7.0 you have possibility of easy creation the so called tuples. A tuple is a finite ordered sequence of elements. Unlike classes and structures, tuples support the so-called "lightweight" syntax: they can be used as groups of ordered data. The explicit usage of inheritance and polymorphism is not allowed. In the simplest case we can create a tuple in the following way (the so called unnamed tuple):
var unnamedPair = (1, 2);
You can get items from a tuple using reserved member names Item1
, Item2
, etc.
Console.WriteLine(unnamedPair.Item1); Console.WriteLine(unnamedPair.Item2);
The better approach assumes usage of named tuples:
var namedPair = (First: 1, Second: 2); Console.WriteLine(namedPair.First); Console.WriteLine(namedPair.Second);
Types of tuple items are extracted from initial values. You can define them explicitly at creation of variables:
(int, int) unnamedPair = (1, 2); (int First, int Second) namedPair = (First: 1, Second: 2);
You can also use values of variables for initialization of tuples:
var integer = 1; var real = 1.5; var tuple = (First: integer, Second: real); Console.WriteLine($"{tuple.First} {tuple.Second}");
Tuples can be used as function arguments:
static int Sum((int, int) args) { return args.Item1 + args.Item2; } static void Main(string[] args) { Console.WriteLine(Sum((1, 2))); }
The one of most essential benefits of tuples is the possibility of returning several values from a function. The following example demonstrates usage of tuples for returning two values from a function. This function solves a linear equation. The first value is the number of roots (-1 if the number of roots is infinite). The second value is the root of the equation:
static (int rootsCount, double? root) SolveLinear(double a, double b) { if (a != 0) { return (1, -b / a); } if (b != 0) { return (0, null); } return (-1, null); } static void Main(string[] args) { Console.WriteLine(SolveLinear(1, -2)); // (1, 2) Console.WriteLine(SolveLinear(0, -2)); // (0, ) Console.WriteLine(SolveLinear(0, 0)); // (-1, ) }
2.9 Methods that Extend Existing Types
Sometimes you need to add new methods to the previously created class. Traditionally, there are three ways to solve this problem:
- modification of the source code. Of course, this approach cannot be correct. Furthermore, the modification of the source code can be impossible, for example, in the case of .NET standard classes and structures, or even when we use the classes provided in compiled form;
- creation of derived class, which provides necessary methods. This approach has numerous limitations. For example,
overloaded operators cannot be applied to objects of derived classes, hence the corresponding operator functions
should be defined again. In addition, derived classes are not part of the .NET class library and their names cannot
be C# keywords, (like
string
). But the most important is that structures do not support inheritance mechanism; - creation of a static function with parameter of type that we want to expand. This is the correct approach, but it is associated with some inconveniences. In particular, outside of class, which identified these features, you must use the appropriate prefix.
C# 3.0 (and later versions) provides the ability to add new methods to existing classes and structures. To add
a new method (for example, called newMethod
), follow these steps:
- create a new static class
- add a new static function (
newMethod
); the first argument of a new method must be reference to object of type (class or structure), to which we want to add a new method; thethis
modifier must precede the definition of the first argument.
Now you can use this function as a non-static method applied to objects of desired type. For example, you can extend
the int
type with method that triples the corresponding integer value. You should create a new
static class:
namespace Extensions { public static class IntExtensions { public static int Triple(this int k) { return k * 3; } } }
You can call the Triple()
method for variables and constants to the appropriate type:
int n = 2; int m = n.Triple(); int k = 9.Triple();
The Triple()
method can be invoked in static way:
int q = IntExtensions.Triple(m);
Visibility of the Triple()
method is limited to the current namespace.
You can create methods with multiple arguments. For example:
public static class DoubleExt { public static double Add(this double a, double b) { return a + b; } } class Program { static void Main(string[] args) { double x = 2.5.Add(3); Console.WriteLine(x); // 5.5 } }
2.10 Nested Types
C# allows creation of nested types. Typically, these are nested classes.
You can apply visibility directives to nested classes. Public nested classes can be used outside the outer class, but private can be used only inside. From the outer class there is no access to the private members of nested classes.
public class Outer { static int i = 12; public class FirstInner { public void F() { i = 10; } } private class SecondInner { public int z; private int x; } SecondInner si = new SecondInner(); public void g() { si.z = 12; si.x = 13; // Error! } } class Program { static void Main(string[] args) { Outer.FirstInner first = new Outer.FirstInner(); first.F(); Outer.SecondInner s; // Error! } }
You cannot create local and anonymous classes. You can create nested structures and enumerations. For example:
struct Polyline { public struct Point { public double X, Y; } public Point[] Points { get; set; } }
You can also create nested types within interfaces.
3 Sample Programs
3.1 Linear Equation
Suppose we want to design a class that represents a linear equation. The fields of this class are coefficients a and b, and the root (x), which should be found. The following program can be created:
using System; namespace LinearEquation { public class LinearEquation { public double A { get; set; } public double B { get; set; } public double? X { get; private set; } public LinearEquation() { A = B = 0; X = null; } public int Solve() { if (A == 0) { if (B == 0) { return -1; // infinite number of roots } else { return 0; // no roots } } X = -B / A; return 1; // one root } } class Program { static void Main(string[] args) { LinearEquation e = new LinearEquation(); e.A = double.Parse(Console.ReadLine() ?? "0"); e.B = double.Parse(Console.ReadLine() ?? "0"); switch (e.Solve()) { case -1: Console.WriteLine("Infinite number of roots"); break; case 0: Console.WriteLine("No roots"); break; case 1: Console.WriteLine("X = " + e.X); break; } } } }
3.2 Solving an Equation with two Roots
Suppose we need to solve the following equation:
Three ways can be offered
The First Way
We can create a function that returns false if b < 0 and the equation cannot be solved. If the roots
can be found, the function will return true
. Since we get two roots,
it is advisable to use parameters with out
attribute:
class Program { static bool SolveEquation(double b, out double? x1, out double? x2) { if (b < 0) { x1 = x2 = null; return false; } x1 = b; x2 = -b; return true; } static void Main(string[] args) { double? x1, x2; double[] bValues = { 3, -3 }; foreach (double b in bValues) { if (SolveEquation(b, out x1, out x2)) { Console.WriteLine("x1 = " + x1 + " x2 = " + x2); } else { Console.WriteLine("No solution"); } } } }
As can be seen from the code, the roots are defined as nullable, because in the case where the equation has no solutions, the values of the parameters after the function call cannot remain undefined, and any other value can potentially be understood as a true root of the equation.
The Second Way
The second way involves creating a special structure for presenting the results:
class Program { struct Result { public bool Solved { get; set; } public double? X1 { get; set; } public double? X2 { get; set; } } static Result SolveEquation(double b) { Result result = new(); if (b < 0) { result.X1 = null; result.X2 = null; result.Solved = false; } else { result.X1 = b; result.X2 = -b; result.Solved = true; } return result; } static void Main(string[] args) { double[] bValues = { 3, -3 }; foreach (double b in bValues) { var result = SolveEquation(b); if (result.Solved) { Console.WriteLine("x1 = " + result.X1 + " x2 = " + result.X2); } else { Console.WriteLine("No solution"); } } } }
The Third Way
The third implementation option is a function that returns a tuple. This option is similar to using the structure:
class Program { static (bool solved, double? x1, double? x2) SolveEquation(double b) { if (b < 0) { return (false, null, null); } return (true, b, -b); } static void Main(string[] args) { double[] bValues = { 3, -3 }; foreach (double b in bValues) { var result = SolveEquation(b); if (result.solved) { Console.WriteLine("x1 = " + result.x1 + " x2 = " + result.x2); } else { Console.WriteLine("No solution"); } } } }
3.3 Processing Data about Books on Bookshelf
In the previous laboratory training, an example of a program for working with an array of book titles was considered. Usually, information about a book is not limited to its title. Important information is author or authors, year of publication, publisher. We can also add the number of pages, format, etc. If we try to write all this information in one string, we will get long strings that are poorly perceived and difficult to process. Structures, records, and other data grouping tools are used to work with such objects of the real world in various languages. In C#, it makes sense to create a class to represent a book.
In addition, books can be located in libraries, on shelves, in cabinets, etc. We can offer classes to represent such entities:
- bookshelf
- book
- author
It is necessary to implement functions to initialize data, search for books whose titles contain a specified sequence of characters, sort books by alphabetically ignoring case, and console output of results.
The first approximation of the object model is to create three classes for three entities, respectively. But it should be noted that in this case, each class will be responsible not only for storing the relevant data, but also for obtaining a string representation for outputting data to the console window. Additionally, the bookshelf representation class will be responsible for searching and sorting. This approach violates the Single Responsibility Principle (SRP).
In order to somehow improve the situation, we can create separate classes for searching, printing, etc. We can group methods to get a string representation of different data and define them as static. We will get the following set of classes:
/// <summary> /// A namespace that contains the classes to represent the bookshelf /// </summary> namespace Bookshelf { /// <summary> /// Represents the author of the book on the bookshelf /// </summary> public class Author { public string Name { get; set; } = ""; public string Surname { get; set; } = ""; public Author() { } public Author(string name, string surname) { Name = name; Surname = surname; } /// <summary> /// Provides a string representation of author data /// </summary> /// <param name="author">author of the book</param> /// <returns>a string representing the author of the book</returns> public static implicit operator string(Author author) { return StringRepresentations.ToString(author); } } /// <summary> /// Represents a book on a bookshelf /// </summary> public class Book { public string Title { get; set; } = ""; public int Year { get; set; } public Author[] Authors { get; set; } = { }; public Book() { } public Book(string title, int year) { Title = title; Year = year; } /// <summary> /// Provides a string representation of the book data /// </summary> /// <param name="book">book</param> /// <returns>a string representing data about the book</returns> public static implicit operator string(Book book) { return StringRepresentations.ToString(book); } } /// <summary> /// Bookshelf /// </summary> public class Bookshelf { public Book[] Books { get; set; } = { }; /// <summary> /// An indexer that allows getting a book by index /// </summary> /// <param name="index">book index</param> /// <returns>book with corresponding index</returns> public Book this[int index] { get => Books[index]; set => Books[index] = value; } /// <summary> /// Constructor /// </summary> /// <param name="books">open array of books</param> public Bookshelf(params Book[] books) { Books = books; } /// <summary> /// Provides a string representation of the bookshelf data /// </summary> /// <param name="bookshelf">bookshelf</param> /// <returns>a string representing the bookshelf data</returns> public static implicit operator string(Bookshelf bookshelf) { return StringRepresentations.ToString(bookshelf); } } /// <summary> /// A static class that provides a string representation /// of various application objects /// </summary> public static class StringRepresentations { /// <summary> /// Provides a string representation of author data /// </summary> /// <param name="author">author of the book</param> /// <returns>a string representing the author of the book</returns> public static string ToString(Author author) { return author.Name + " " + author.Surname; ; } /// <summary> /// Provides a string representation of the book data /// </summary> /// <param name="book">book</param> /// <returns>a string representing data about the book</returns> public static string ToString(Book book) { if (book == null) { return ""; } string result = ""; result += string.Format("Book. Title: \"{0}\". Year of publication: {1}", book.Title, book.Year); result += " Authors:\n"; for (int i = 0; i < book.Authors.Length; i++) { result += string.Format(" {0}", (string)book.Authors[i]); result += (i < book.Authors.Length - 1 ? "," : "") + "\n"; } return result; } /// <summary> /// Provides a string representation of the bookshelf data /// </summary> /// <param name="bookshelf">bookshelf</param> /// <returns>a string representing the bookshelf data</returns> public static string ToString(Bookshelf bookshelf) { string result = ""; foreach (Book book in bookshelf.Books) { result += book + "\n"; } return result; } } /// <summary> /// Provides methods for finding and sorting books on a shelf /// </summary> public static class BookHandle { /// <summary> /// Searches for a specified sequence of characters in book titles /// </summary> /// <param name="bookshelf">bookshelf</param> /// <param name="characters">character sequence to find</param> /// <returns>an array of books whose titles contain the specified sequence</returns> public static Book[] ContainsCharacters(Bookshelf bookshelf, string characters) { return Array.FindAll(bookshelf.Books, book => book.Title.Contains(characters)); } /// <summary> /// Sorts books alphabetically by name ignoring case /// </summary> /// <param name="bookshelf">bookshelf</param> public static void SortByTitles(Bookshelf bookshelf) { Array.Sort(bookshelf.Books, (b1, b2) => string.Compare(b1.Title.ToUpper(), b2.Title.ToUpper())); } } }
In the Program
class we can define separate methods for preparing the necessary data and performing
data search and sorting:
namespace Bookshelf; /// <summary> /// A console application to demonstrate working with books on a bookshelf /// </summary> class Program { /// <summary> /// Prepares test data to demonstrate working with books on a bookshelf /// </summary> /// <returns>Bookshelf with added books</returns> public static Bookshelf CreateBookshelf() { return new Bookshelf( new Book(@"The UML User Guide", 1999) { Authors = new[] { new Author("Grady", "Booch"), new Author("James", "Rumbaugh"), new Author("Ivar", "Jacobson") } }, new Book(@"Pro C# 2010 and the .NET 4 Platform", 2010) { Authors = new[] { new Author("Andrew", "Troelsen") } }, new Book(@"Thinking in Java", 2005) { Authors = new[] { new Author("Bruce", "Eckel") } }, new Book(@"Design Patterns: Elements of Reusable Object-Oriented Software", 1994) { Authors = new[] { new Author("Erich", "Gamma"), new Author("Richard", "Helm"), new Author("Ralph", "Johnson") new Author("John", "Vlissides") } }, new Book(@"C# 9.0 in a Nutshell: The Definitive Reference", 2021) { Authors = new[] { new Author("Joseph", "Albahari") } } ); } /// <summary> /// Demonstrates how to search and sort books /// </summary> /// <param name="bookshelf">the bookshelf for which the work is demonstrated</param> public static void HandleBookshelf(Bookshelf bookshelf) { Console.WriteLine("\nInitial state:"); Console.WriteLine(bookshelf); Console.WriteLine("\nTitles that contain \"The\""); var result = BookHandle.ContainsCharacters(bookshelf, "The"); foreach (var book in result) { Console.WriteLine(book.Title); } //Console.WriteLine(result.ToArray().Length > 0 ? string.Join("\n", result) : "No"); Console.WriteLine("\nAlphabetically ignoring case:"); BookHandle.SortByTitles(bookshelf); Console.WriteLine(bookshelf); } /// <summary> /// The starting point of the console application /// </summary> static void Main() { Console.OutputEncoding = System.Text.Encoding.UTF8; HandleBookshelf(CreateBookshelf()); } }
4 Exercises
- Create classes with constructors and properties to describe the student and his (her) grades.
- Create a class with a constructor for description of some product (title and price are stored).
- Create a class with a constructor to describe the user (username and password).
- Create a function with two reference type parameters that increases one parameter by 1 and decreases the other by 2.
- Create a class for representation of a parallelepiped. Provide access to the length, width and height as by property names, and through the indexer.
- Expand
int
type with method of calculating the square. - Expand
double
type with method of calculating the third power. - Expand
int
type with method of calculating factorial. - Expand
double
type with method of calculating integer power. - Expand
System.String
class with method of removal the first and the last characters. - Expand
System.String
class with method of check whether first and last letters are the same. - Create a class "Group of people". Implement operator overloading for adding (+) and removal (-) group members. Use object initializers.
- Create a Class "A simple fraction". Implement overloading for operations +, -, and *.
- Create a class "Group" with nested structure for the representation of student and an array of students.
5 Quiz
- What are C# namespaces and what is the purpose of creating them?
- How to create namespaces? How to access names from namespaces?
- What are main elements of class definition?
- What is the content of encapsulation?
- What visibility levels of class elements supports C#?
- How to determine access level within assembly or within namespace?
- What is the usage of
this
reference? - Concept of properties. What are features of properties?
- How to create a write-only and read-only properties?
- What are advantages and disadvantages of automatic properties?
- How to invoke constructor from another constructor?
- What are the advantages and disadvantages of object initializers compared to constructors?
- Destructors in C#. When destructors are called?
- What are the differences between static and non-static class members?
- What is a static constructor and when is it called?
- Use of
readonly
modifier. How to initializereadonly
members? - How is a static class described?
- What are differences between
ref
andout
modifiers? - What is the operation overload used for?
- How to describe an operator function?
- Description of indexers. What is different in use of objects with indexers and arrays?
- What is the reason of using anonymous types?
- What are the features of creating and using records?
- What are the advantages and disadvantages of structures?
- What are the features of creating structure type objects using
new
operator? - How can we describe and use the enumeration?
- Syntax and advantages of usage tuples.
- How to describe a method, which is added to an existing class?